Skip to content
This repository was archived by the owner on Mar 14, 2025. It is now read-only.

Commit a3c3add

Browse files
committed
feat: add recursion endpoints
1 parent 49cdaff commit a3c3add

File tree

7 files changed

+368
-32
lines changed

7 files changed

+368
-32
lines changed

src/api/init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SatRoutes } from './routes/sats';
1111
import { StatsRoutes } from './routes/stats';
1212
import { StatusRoutes } from './routes/status';
1313
import { isProdEnv } from './util/helpers';
14+
import { RecursionRoutes } from './routes/recursion';
1415

1516
export const Api: FastifyPluginAsync<
1617
Record<never, never>,
@@ -21,6 +22,7 @@ export const Api: FastifyPluginAsync<
2122
await fastify.register(InscriptionsRoutes);
2223
await fastify.register(SatRoutes);
2324
await fastify.register(StatsRoutes);
25+
await fastify.register(RecursionRoutes);
2426
};
2527

2628
export async function buildApiServer(args: { db: PgStore }) {

src/api/routes/recursion.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
2+
import { Type } from '@sinclair/typebox';
3+
import { FastifyPluginAsync, FastifyPluginCallback } from 'fastify';
4+
import { Server } from 'http';
5+
import {
6+
BlockHashResponse,
7+
BlockHeightParam,
8+
BlockHeightResponse,
9+
BlockTimestampResponse,
10+
NotFoundResponse,
11+
} from '../schemas';
12+
13+
const IndexRoutes: FastifyPluginCallback<Record<never, never>, Server, TypeBoxTypeProvider> = (
14+
fastify,
15+
options,
16+
done
17+
) => {
18+
// todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well)
19+
// fastify.addHook('preHandler', handleInscriptionTransfersCache);
20+
21+
fastify.get(
22+
'/blockheight',
23+
{
24+
schema: {
25+
operationId: 'getBlockHeight',
26+
summary: 'Recursion',
27+
description: 'Retrieves the latest block height',
28+
tags: ['Recursion'],
29+
response: {
30+
200: BlockHeightResponse,
31+
404: NotFoundResponse,
32+
},
33+
},
34+
},
35+
async (request, reply) => {
36+
const blockHeight = (await fastify.db.getChainTipBlockHeight()) ?? 'blockheight';
37+
// Currently, the `chain_tip` materialized view should always return a
38+
// minimum of 767430 (inscription #0 genesis), but we'll keep the fallback
39+
// to stay consistent with `ord`.
40+
41+
await reply.send(blockHeight.toString());
42+
}
43+
);
44+
45+
fastify.get(
46+
'/blockhash',
47+
{
48+
schema: {
49+
operationId: 'getBlockHash',
50+
summary: 'Recursion',
51+
description: 'Retrieves the latest block hash',
52+
tags: ['Recursion'],
53+
response: {
54+
200: BlockHashResponse,
55+
404: NotFoundResponse,
56+
},
57+
},
58+
},
59+
async (request, reply) => {
60+
const blockHash = (await fastify.db.getBlockHash()) ?? 'blockhash';
61+
await reply.send(blockHash);
62+
}
63+
);
64+
65+
fastify.get(
66+
'/blocktime',
67+
{
68+
schema: {
69+
operationId: 'getBlockTime',
70+
summary: 'Recursion',
71+
description: 'Retrieves the latest block time',
72+
tags: ['Recursion'],
73+
response: {
74+
200: BlockTimestampResponse,
75+
404: NotFoundResponse,
76+
},
77+
},
78+
},
79+
async (request, reply) => {
80+
const blockTime = (await fastify.db.getBlockTimestamp()) ?? 'blocktime';
81+
await reply.send(blockTime);
82+
}
83+
);
84+
85+
done();
86+
};
87+
88+
const ShowRoutes: FastifyPluginCallback<Record<never, never>, Server, TypeBoxTypeProvider> = (
89+
fastify,
90+
options,
91+
done
92+
) => {
93+
// todo: add blockheight cache? or re-use the inscriptions per block cache (since that would invalidate on gaps as well)
94+
// fastify.addHook('preHandler', handleInscriptionCache);
95+
96+
fastify.get(
97+
'/blockhash/:block_height',
98+
{
99+
schema: {
100+
operationId: 'getBlockHash',
101+
summary: 'Recursion',
102+
description: 'Retrieves the block hash for a given block height',
103+
tags: ['Recursion'],
104+
params: Type.Object({
105+
block_height: BlockHeightParam,
106+
}),
107+
response: {
108+
200: BlockHashResponse,
109+
404: NotFoundResponse,
110+
},
111+
},
112+
},
113+
async (request, reply) => {
114+
const blockHash = (await fastify.db.getBlockHash(request.params.block_height)) ?? 'blockhash';
115+
await reply.send(blockHash);
116+
}
117+
);
118+
119+
done();
120+
};
121+
122+
export const RecursionRoutes: FastifyPluginAsync<
123+
Record<never, never>,
124+
Server,
125+
TypeBoxTypeProvider
126+
> = async fastify => {
127+
await fastify.register(IndexRoutes);
128+
await fastify.register(ShowRoutes);
129+
};

src/api/schemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,14 @@ export const InscriptionsPerBlockResponse = Type.Object({
351351
results: Type.Array(InscriptionsPerBlock),
352352
});
353353
export type InscriptionsPerBlockResponse = Static<typeof InscriptionsPerBlockResponse>;
354+
355+
export const BlockHeightResponse = Type.String({ examples: ['778921'] });
356+
export type BlockHeightResponse = Static<typeof BlockHeightResponse>;
357+
358+
export const BlockHashResponse = Type.String({
359+
examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'],
360+
});
361+
export type BlockHashResponse = Static<typeof BlockHashResponse>;
362+
363+
export const BlockTimestampResponse = Type.String({ examples: ['1677733170000'] });
364+
export type BlockTimestampResponse = Static<typeof BlockTimestampResponse>;

src/pg/pg-store.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,36 @@ export class PgStore extends BasePgStore {
209209
return parseInt(result[0].block_height);
210210
}
211211

212+
/**
213+
* Returns the block hash of the latest block, or the block hash of the block
214+
* at the given height.
215+
* @param blockHeight - optional block height (defaults to latest block)
216+
*/
217+
async getBlockHash(blockHeight?: string): Promise<string> {
218+
const clause = blockHeight
219+
? this.sql`WHERE block_height = ${blockHeight}`
220+
: this.sql`
221+
ORDER BY block_height DESC
222+
LIMIT 1
223+
`;
224+
225+
const result = await this.sql<{ block_hash: string }[]>`
226+
SELECT block_hash FROM inscriptions_per_block
227+
${clause}
228+
`;
229+
230+
return result[0]?.block_hash;
231+
}
232+
233+
async getBlockTimestamp(): Promise<string> {
234+
const result = await this.sql<{ timestamp: string }[]>`
235+
SELECT ROUND(EXTRACT(EPOCH FROM timestamp)) as timestamp FROM inscriptions_per_block
236+
ORDER BY block_height DESC
237+
LIMIT 1
238+
`;
239+
return result[0]?.timestamp;
240+
}
241+
212242
async getChainTipInscriptionCount(): Promise<number> {
213243
const result = await this.sql<{ count: number }[]>`
214244
SELECT count FROM inscription_count

tests/helpers.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,37 @@ export class TestChainhookPayloadBuilder {
108108
/** Generate a random hash like string for testing */
109109
export const randomHash = () =>
110110
[...Array(64)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');
111+
112+
/** Generate a random-ish reveal apply payload for testing */
113+
export function testRevealApply(
114+
blockHeight: number,
115+
args: { blockHash?: string; timestamp?: number } = {}
116+
) {
117+
// todo: more params could be randomized
118+
const randomHex = randomHash();
119+
return new TestChainhookPayloadBuilder()
120+
.apply()
121+
.block({
122+
height: blockHeight,
123+
hash: args.blockHash ?? '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d',
124+
timestamp: args.timestamp ?? 1676913207,
125+
})
126+
.transaction({
127+
hash: `0x${randomHex}`,
128+
})
129+
.inscriptionRevealed({
130+
content_bytes: '0x48656C6C6F',
131+
content_type: 'image/png',
132+
content_length: 5,
133+
inscription_number: Math.floor(Math.random() * 100_000),
134+
inscription_fee: 2805,
135+
inscription_id: `${randomHex}i0`,
136+
inscription_output_value: 10000,
137+
inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
138+
ordinal_number: Math.floor(Math.random() * 1_000_000),
139+
ordinal_block_height: Math.floor(Math.random() * 777_000),
140+
ordinal_offset: 0,
141+
satpoint_post_inscription: `${randomHex}:0:0`,
142+
})
143+
.build();
144+
}

tests/recursion.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { buildApiServer } from '../src/api/init';
2+
import { cycleMigrations } from '../src/pg/migrations';
3+
import { PgStore } from '../src/pg/pg-store';
4+
import { TestFastifyServer, randomHash, testRevealApply } from './helpers';
5+
6+
describe('recursion routes', () => {
7+
let db: PgStore;
8+
let fastify: TestFastifyServer;
9+
10+
beforeEach(async () => {
11+
db = await PgStore.connect({ skipMigrations: true });
12+
fastify = await buildApiServer({ db });
13+
await cycleMigrations();
14+
});
15+
16+
afterEach(async () => {
17+
await fastify.close();
18+
await db.close();
19+
});
20+
21+
describe('/blockheight', () => {
22+
test('returns default `blockheight` when no blocks found', async () => {
23+
const response = await fastify.inject({
24+
method: 'GET',
25+
url: '/ordinals/v1/blockheight',
26+
});
27+
expect(response.statusCode).toBe(200);
28+
expect(response.body).toBe('767430');
29+
expect(response.headers).toEqual(
30+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
31+
);
32+
});
33+
34+
test('returns latest block height', async () => {
35+
await db.updateInscriptions(testRevealApply(778_001));
36+
37+
let response = await fastify.inject({
38+
method: 'GET',
39+
url: '/ordinals/v1/blockheight',
40+
});
41+
expect(response.statusCode).toBe(200);
42+
expect(response.body).toBe('778001');
43+
expect(response.headers).toEqual(
44+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
45+
);
46+
47+
await db.updateInscriptions(testRevealApply(778_002));
48+
49+
response = await fastify.inject({
50+
method: 'GET',
51+
url: '/ordinals/v1/blockheight',
52+
});
53+
expect(response.statusCode).toBe(200);
54+
expect(response.body).toBe('778002');
55+
expect(response.headers).toEqual(
56+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
57+
);
58+
});
59+
});
60+
61+
describe('/blockhash', () => {
62+
test('returns default `blockhash` when no blocks found', async () => {
63+
const response = await fastify.inject({
64+
method: 'GET',
65+
url: '/ordinals/v1/blockhash',
66+
});
67+
expect(response.statusCode).toBe(200);
68+
expect(response.body).toBe('blockhash');
69+
expect(response.headers).toEqual(
70+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
71+
);
72+
});
73+
74+
test('returns latest block hash', async () => {
75+
let blockHash = randomHash();
76+
await db.updateInscriptions(testRevealApply(778_001, { blockHash }));
77+
78+
let response = await fastify.inject({
79+
method: 'GET',
80+
url: '/ordinals/v1/blockhash',
81+
});
82+
expect(response.statusCode).toBe(200);
83+
expect(response.body).toBe(blockHash);
84+
expect(response.headers).toEqual(
85+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
86+
);
87+
88+
blockHash = randomHash();
89+
await db.updateInscriptions(testRevealApply(778_002, { blockHash }));
90+
91+
response = await fastify.inject({
92+
method: 'GET',
93+
url: '/ordinals/v1/blockhash',
94+
});
95+
expect(response.statusCode).toBe(200);
96+
expect(response.body).toBe(blockHash);
97+
expect(response.headers).toEqual(
98+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
99+
);
100+
});
101+
102+
test('returns block hash by block height', async () => {
103+
const blockHash = randomHash();
104+
await db.updateInscriptions(testRevealApply(778_001));
105+
await db.updateInscriptions(testRevealApply(778_002, { blockHash }));
106+
await db.updateInscriptions(testRevealApply(778_003));
107+
108+
const response = await fastify.inject({
109+
method: 'GET',
110+
url: `/ordinals/v1/blockhash/778002`,
111+
});
112+
expect(response.statusCode).toBe(200);
113+
expect(response.body).toBe(blockHash);
114+
expect(response.headers).toEqual(
115+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
116+
);
117+
});
118+
});
119+
120+
describe('/blocktime', () => {
121+
test('returns default `blocktime` when no blocks found', async () => {
122+
const response = await fastify.inject({
123+
method: 'GET',
124+
url: '/ordinals/v1/blocktime',
125+
});
126+
expect(response.statusCode).toBe(200);
127+
expect(response.body).toBe('blocktime');
128+
expect(response.headers).toEqual(
129+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
130+
);
131+
});
132+
133+
test('returns latest block timestamp', async () => {
134+
let timestamp = Date.now();
135+
await db.updateInscriptions(testRevealApply(778_001, { timestamp }));
136+
137+
let response = await fastify.inject({
138+
method: 'GET',
139+
url: '/ordinals/v1/blocktime',
140+
});
141+
expect(response.statusCode).toBe(200);
142+
expect(response.body).toBe(timestamp.toString());
143+
expect(response.headers).toEqual(
144+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
145+
);
146+
147+
timestamp = Date.now();
148+
await db.updateInscriptions(testRevealApply(778_002, { timestamp }));
149+
150+
response = await fastify.inject({
151+
method: 'GET',
152+
url: '/ordinals/v1/blocktime',
153+
});
154+
expect(response.statusCode).toBe(200);
155+
expect(response.body).toBe(timestamp.toString());
156+
expect(response.headers).toEqual(
157+
expect.objectContaining({ 'content-type': 'text/plain; charset=utf-8' })
158+
);
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)