diff --git a/src/api/init.ts b/src/api/init.ts index 77e9d71b..1083f65c 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -11,6 +11,7 @@ import { SatRoutes } from './routes/sats'; import { StatsRoutes } from './routes/stats'; import { StatusRoutes } from './routes/status'; import { isProdEnv } from './util/helpers'; +import { RecursionRoutes } from './routes/recursion'; export const Api: FastifyPluginAsync< Record, @@ -21,6 +22,7 @@ export const Api: FastifyPluginAsync< await fastify.register(InscriptionsRoutes); await fastify.register(SatRoutes); await fastify.register(StatsRoutes); + await fastify.register(RecursionRoutes); }; export async function buildApiServer(args: { db: PgStore }) { diff --git a/src/api/routes/recursion.ts b/src/api/routes/recursion.ts new file mode 100644 index 00000000..8b139e45 --- /dev/null +++ b/src/api/routes/recursion.ts @@ -0,0 +1,129 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; +import { FastifyPluginAsync, FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { + BlockHashResponse, + BlockHeightParam, + BlockHeightResponse, + BlockTimestampResponse, + NotFoundResponse, +} from '../schemas'; + +const IndexRoutes: FastifyPluginCallback, Server, TypeBoxTypeProvider> = ( + fastify, + options, + done +) => { + // todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well) + // fastify.addHook('preHandler', handleInscriptionTransfersCache); + + fastify.get( + '/blockheight', + { + schema: { + operationId: 'getBlockHeight', + summary: 'Recursion', + description: 'Retrieves the latest block height', + tags: ['Recursion'], + response: { + 200: BlockHeightResponse, + 404: NotFoundResponse, + }, + }, + }, + async (request, reply) => { + const blockHeight = (await fastify.db.getChainTipBlockHeight()) ?? 'blockheight'; + // Currently, the `chain_tip` materialized view should always return a + // minimum of 767430 (inscription #0 genesis), but we'll keep the fallback + // to stay consistent with `ord`. + + await reply.send(blockHeight.toString()); + } + ); + + fastify.get( + '/blockhash', + { + schema: { + operationId: 'getBlockHash', + summary: 'Recursion', + description: 'Retrieves the latest block hash', + tags: ['Recursion'], + response: { + 200: BlockHashResponse, + 404: NotFoundResponse, + }, + }, + }, + async (request, reply) => { + const blockHash = (await fastify.db.getBlockHash()) ?? 'blockhash'; + await reply.send(blockHash); + } + ); + + fastify.get( + '/blocktime', + { + schema: { + operationId: 'getBlockTime', + summary: 'Recursion', + description: 'Retrieves the latest block time', + tags: ['Recursion'], + response: { + 200: BlockTimestampResponse, + 404: NotFoundResponse, + }, + }, + }, + async (request, reply) => { + const blockTime = (await fastify.db.getBlockTimestamp()) ?? 'blocktime'; + await reply.send(blockTime); + } + ); + + done(); +}; + +const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTypeProvider> = ( + fastify, + options, + done +) => { + // todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well) + // fastify.addHook('preHandler', handleInscriptionCache); + + fastify.get( + '/blockhash/:block_height', + { + schema: { + operationId: 'getBlockHash', + summary: 'Recursion', + description: 'Retrieves the block hash for a given block height', + tags: ['Recursion'], + params: Type.Object({ + block_height: BlockHeightParam, + }), + response: { + 200: BlockHashResponse, + 404: NotFoundResponse, + }, + }, + }, + async (request, reply) => { + const blockHash = (await fastify.db.getBlockHash(request.params.block_height)) ?? 'blockhash'; + await reply.send(blockHash); + } + ); + + done(); +}; + +export const RecursionRoutes: FastifyPluginAsync< + Record, + Server, + TypeBoxTypeProvider +> = async fastify => { + await fastify.register(IndexRoutes); + await fastify.register(ShowRoutes); +}; diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c48c7c84..b539d6d2 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -351,3 +351,14 @@ export const InscriptionsPerBlockResponse = Type.Object({ results: Type.Array(InscriptionsPerBlock), }); export type InscriptionsPerBlockResponse = Static; + +export const BlockHeightResponse = Type.String({ examples: ['778921'] }); +export type BlockHeightResponse = Static; + +export const BlockHashResponse = Type.String({ + examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'], +}); +export type BlockHashResponse = Static; + +export const BlockTimestampResponse = Type.String({ examples: ['1677733170000'] }); +export type BlockTimestampResponse = Static; diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 6637ea01..11669518 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -209,6 +209,36 @@ export class PgStore extends BasePgStore { return parseInt(result[0].block_height); } + /** + * Returns the block hash of the latest block, or the block hash of the block + * at the given height. + * @param blockHeight - optional block height (defaults to latest block) + */ + async getBlockHash(blockHeight?: string): Promise { + const clause = blockHeight + ? this.sql`WHERE block_height = ${blockHeight}` + : this.sql` + ORDER BY block_height DESC + LIMIT 1 + `; + + const result = await this.sql<{ block_hash: string }[]>` + SELECT block_hash FROM inscriptions_per_block + ${clause} + `; + + return result[0]?.block_hash; + } + + async getBlockTimestamp(): Promise { + const result = await this.sql<{ timestamp: string }[]>` + SELECT ROUND(EXTRACT(EPOCH FROM timestamp)) as timestamp FROM inscriptions_per_block + ORDER BY block_height DESC + LIMIT 1 + `; + return result[0]?.timestamp; + } + async getChainTipInscriptionCount(): Promise { const result = await this.sql<{ count: number }[]>` SELECT count FROM inscription_count diff --git a/tests/helpers.ts b/tests/helpers.ts index 9f29a4fe..fa067033 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -108,3 +108,37 @@ export class TestChainhookPayloadBuilder { /** Generate a random hash like string for testing */ export const randomHash = () => [...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); + +/** Generate a random-ish reveal apply payload for testing */ +export function testRevealApply( + blockHeight: number, + args: { blockHash?: string; timestamp?: number } = {} +) { + // todo: more params could be randomized + const randomHex = randomHash(); + return new TestChainhookPayloadBuilder() + .apply() + .block({ + height: blockHeight, + hash: args.blockHash ?? '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + timestamp: args.timestamp ?? 1676913207, + }) + .transaction({ + hash: `0x${randomHex}`, + }) + .inscriptionRevealed({ + content_bytes: '0x48656C6C6F', + content_type: 'image/png', + content_length: 5, + inscription_number: Math.floor(Math.random() * 100_000), + inscription_fee: 2805, + inscription_id: `${randomHex}i0`, + inscription_output_value: 10000, + inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + ordinal_number: Math.floor(Math.random() * 1_000_000), + ordinal_block_height: Math.floor(Math.random() * 777_000), + ordinal_offset: 0, + satpoint_post_inscription: `${randomHex}:0:0`, + }) + .build(); +} diff --git a/tests/recursion.test.ts b/tests/recursion.test.ts new file mode 100644 index 00000000..cc811742 --- /dev/null +++ b/tests/recursion.test.ts @@ -0,0 +1,161 @@ +import { buildApiServer } from '../src/api/init'; +import { cycleMigrations } from '../src/pg/migrations'; +import { PgStore } from '../src/pg/pg-store'; +import { TestFastifyServer, randomHash, testRevealApply } from './helpers'; + +describe('recursion routes', () => { + let db: PgStore; + let fastify: TestFastifyServer; + + beforeEach(async () => { + db = await PgStore.connect({ skipMigrations: true }); + fastify = await buildApiServer({ db }); + await cycleMigrations(); + }); + + afterEach(async () => { + await fastify.close(); + await db.close(); + }); + + describe('/blockheight', () => { + test('returns default `blockheight` when no blocks found', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe('767430'); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + + test('returns latest block height', async () => { + await db.updateInscriptions(testRevealApply(778_001)); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe('778001'); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + + await db.updateInscriptions(testRevealApply(778_002)); + + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockheight', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe('778002'); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + }); + + describe('/blockhash', () => { + test('returns default `blockhash` when no blocks found', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe('blockhash'); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + + test('returns latest block hash', async () => { + let blockHash = randomHash(); + await db.updateInscriptions(testRevealApply(778_001, { blockHash })); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(blockHash); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + + blockHash = randomHash(); + await db.updateInscriptions(testRevealApply(778_002, { blockHash })); + + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blockhash', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(blockHash); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + + test('returns block hash by block height', async () => { + const blockHash = randomHash(); + await db.updateInscriptions(testRevealApply(778_001)); + await db.updateInscriptions(testRevealApply(778_002, { blockHash })); + await db.updateInscriptions(testRevealApply(778_003)); + + const response = await fastify.inject({ + method: 'GET', + url: `/ordinals/v1/blockhash/778002`, + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(blockHash); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + }); + + describe('/blocktime', () => { + test('returns default `blocktime` when no blocks found', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe('blocktime'); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + + test('returns latest block timestamp', async () => { + let timestamp = Date.now(); + await db.updateInscriptions(testRevealApply(778_001, { timestamp })); + + let response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(timestamp.toString()); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + + timestamp = Date.now(); + await db.updateInscriptions(testRevealApply(778_002, { timestamp })); + + response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/blocktime', + }); + expect(response.statusCode).toBe(200); + expect(response.body).toBe(timestamp.toString()); + expect(response.headers).toEqual( + expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' }) + ); + }); + }); +}); diff --git a/tests/stats.test.ts b/tests/stats.test.ts index 18eea3c9..e4958336 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -1,9 +1,7 @@ import { buildApiServer } from '../src/api/init'; import { cycleMigrations } from '../src/pg/migrations'; import { PgStore } from '../src/pg/pg-store'; -import { TestChainhookPayloadBuilder, TestFastifyServer, randomHash } from './helpers'; - -jest.setTimeout(100_000_000); +import { TestFastifyServer, testRevealApply } from './helpers'; describe('/stats', () => { let db: PgStore; @@ -223,32 +221,3 @@ describe('/stats', () => { }); }); }); - -function testRevealApply(blockHeight: number) { - const randomHex = randomHash(); - return new TestChainhookPayloadBuilder() - .apply() - .block({ - height: blockHeight, - hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', - timestamp: 1676913207, - }) - .transaction({ - hash: `0x${randomHex}`, - }) - .inscriptionRevealed({ - content_bytes: '0x48656C6C6F', - content_type: 'image/png', - content_length: 5, - inscription_number: Math.floor(Math.random() * 100_000), - inscription_fee: 2805, - inscription_id: `${randomHex}i0`, - inscription_output_value: 10000, - inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', - ordinal_number: Math.floor(Math.random() * 1_000_000), - ordinal_block_height: Math.floor(Math.random() * 777_000), - ordinal_offset: 0, - satpoint_post_inscription: `${randomHex}:0:0`, - }) - .build(); -}