Skip to content

Commit

Permalink
feat: add recursion endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
janniks committed Jul 11, 2023
1 parent 49cdaff commit a3c3add
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 32 deletions.
2 changes: 2 additions & 0 deletions src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>,
Expand All @@ -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 }) {
Expand Down
129 changes: 129 additions & 0 deletions src/api/routes/recursion.ts
Original file line number Diff line number Diff line change
@@ -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<Record<never, never>, 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<Record<never, never>, 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<never, never>,
Server,
TypeBoxTypeProvider
> = async fastify => {
await fastify.register(IndexRoutes);
await fastify.register(ShowRoutes);
};
11 changes: 11 additions & 0 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,14 @@ export const InscriptionsPerBlockResponse = Type.Object({
results: Type.Array(InscriptionsPerBlock),
});
export type InscriptionsPerBlockResponse = Static<typeof InscriptionsPerBlockResponse>;

export const BlockHeightResponse = Type.String({ examples: ['778921'] });
export type BlockHeightResponse = Static<typeof BlockHeightResponse>;

export const BlockHashResponse = Type.String({
examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'],
});
export type BlockHashResponse = Static<typeof BlockHashResponse>;

export const BlockTimestampResponse = Type.String({ examples: ['1677733170000'] });
export type BlockTimestampResponse = Static<typeof BlockTimestampResponse>;
30 changes: 30 additions & 0 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
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<number> {
const result = await this.sql<{ count: number }[]>`
SELECT count FROM inscription_count
Expand Down
34 changes: 34 additions & 0 deletions tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
161 changes: 161 additions & 0 deletions tests/recursion.test.ts
Original file line number Diff line number Diff line change
@@ -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' })
);
});
});
});
Loading

0 comments on commit a3c3add

Please sign in to comment.