From e1afa7dbaf29407c9ddacc775ce69782138c591f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20C=C3=A1rdenas?= Date: Wed, 10 May 2023 12:49:14 -0600 Subject: [PATCH] feat: add endpoint to retrieve all inscription transfers per block (#63) --- src/api/routes/inscriptions.ts | 47 ++++++- src/api/schemas.ts | 14 ++- src/api/util/helpers.ts | 43 ++++++- src/pg/pg-store.ts | 55 +++++++- src/pg/types.ts | 35 ++++++ tests/inscriptions.test.ts | 221 +++++++++++++++++++++++++++++++++ 6 files changed, 406 insertions(+), 9 deletions(-) diff --git a/src/api/routes/inscriptions.ts b/src/api/routes/inscriptions.ts index 65dea281..ddfd1863 100644 --- a/src/api/routes/inscriptions.ts +++ b/src/api/routes/inscriptions.ts @@ -28,12 +28,14 @@ import { AddressesParam, InscriptionIdsParam, InscriptionNumbersParam, - InscriptionLocationResponse, + InscriptionLocationResponseSchema, + BlockInscriptionTransferSchema, } from '../schemas'; import { handleInscriptionCache, handleInscriptionTransfersCache } from '../util/cache'; import { DEFAULT_API_LIMIT, hexToBuffer, + parseBlockTransfers, parseDbInscription, parseDbInscriptions, parseInscriptionLocations, @@ -144,6 +146,47 @@ const IndexRoutes: FastifyPluginCallback, Server, TypeBoxTy } ); + fastify.get( + '/inscriptions/transfers', + { + schema: { + operationId: 'getTransfersPerBlock', + summary: 'Transfers per block', + description: + 'Retrieves a list of inscription transfers that ocurred at a specific Bitcoin block', + tags: ['Inscriptions'], + querystring: Type.Object({ + block: BlockParam, + // Pagination + offset: Type.Optional(OffsetParam), + limit: Type.Optional(LimitParam), + }), + response: { + 200: PaginatedResponse( + BlockInscriptionTransferSchema, + 'Paginated Block Transfers Response' + ), + 404: NotFoundResponse, + }, + }, + }, + async (request, reply) => { + const limit = request.query.limit ?? DEFAULT_API_LIMIT; + const offset = request.query.offset ?? 0; + const transfers = await fastify.db.getTransfersPerBlock({ + limit, + offset, + ...blockParam(request.query.block, 'block'), + }); + await reply.send({ + limit, + offset, + total: transfers.total, + results: parseBlockTransfers(transfers.results), + }); + } + ); + done(); }; @@ -235,7 +278,7 @@ const ShowRoutes: FastifyPluginCallback, Server, TypeBoxTyp }), response: { 200: PaginatedResponse( - InscriptionLocationResponse, + InscriptionLocationResponseSchema, 'Paginated Inscription Locations Response' ), 404: NotFoundResponse, diff --git a/src/api/schemas.ts b/src/api/schemas.ts index bb26b871..970efdad 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -285,7 +285,7 @@ export const ApiStatusResponse = Type.Object( { title: 'Api Status Response' } ); -export const InscriptionLocationResponse = Type.Object( +export const InscriptionLocationResponseSchema = Type.Object( { block_height: Type.Integer({ examples: [778921] }), block_hash: Type.String({ @@ -311,7 +311,17 @@ export const InscriptionLocationResponse = Type.Object( }, { title: 'Inscription Location Response' } ); -export type InscriptionLocationResponse = Static; +export type InscriptionLocationResponse = Static; + +export const BlockInscriptionTransferSchema = Type.Object({ + id: Type.String({ + examples: ['1463d48e9248159084929294f64bda04487503d30ce7ab58365df1dc6fd58218i0'], + }), + number: Type.Integer({ examples: [248751] }), + from: InscriptionLocationResponseSchema, + to: InscriptionLocationResponseSchema, +}); +export type BlockInscriptionTransfer = Static; export const NotFoundResponse = Type.Object( { diff --git a/src/api/util/helpers.ts b/src/api/util/helpers.ts index a8f526d9..b4739ca0 100644 --- a/src/api/util/helpers.ts +++ b/src/api/util/helpers.ts @@ -1,5 +1,13 @@ -import { DbFullyLocatedInscriptionResult, DbLocation } from '../../pg/types'; -import { InscriptionLocationResponse, InscriptionResponseType } from '../schemas'; +import { + DbFullyLocatedInscriptionResult, + DbInscriptionLocationChange, + DbLocation, +} from '../../pg/types'; +import { + BlockInscriptionTransfer, + InscriptionLocationResponse, + InscriptionResponseType, +} from '../schemas'; export const DEFAULT_API_LIMIT = 20; @@ -48,6 +56,37 @@ export function parseInscriptionLocations(items: DbLocation[]): InscriptionLocat })); } +export function parseBlockTransfers( + items: DbInscriptionLocationChange[] +): BlockInscriptionTransfer[] { + return items.map(i => ({ + id: i.genesis_id, + number: parseInt(i.number), + from: { + block_height: parseInt(i.from_block_height), + block_hash: i.from_block_hash, + address: i.from_address, + tx_id: i.from_tx_id, + location: `${i.from_output}:${i.from_offset}`, + output: i.from_output, + value: i.from_value, + offset: i.from_offset, + timestamp: i.from_timestamp.valueOf(), + }, + to: { + block_height: parseInt(i.to_block_height), + block_hash: i.to_block_hash, + address: i.to_address, + tx_id: i.to_tx_id, + location: `${i.to_output}:${i.to_offset}`, + output: i.to_output, + value: i.to_value, + offset: i.to_offset, + timestamp: i.to_timestamp.valueOf(), + }, + })); +} + /** * Decodes a `0x` prefixed hex string to a buffer. * @param hex - A hex string with a `0x` prefix. diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 699dbf86..66d31683 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -16,6 +16,7 @@ import { DbInscriptionIndexPaging, DbInscriptionIndexResultCountType, DbInscriptionInsert, + DbInscriptionLocationChange, DbJsonContent, DbLocation, DbLocationInsert, @@ -188,9 +189,9 @@ export class PgStore extends BasePgStore { } async getMaxInscriptionNumber(): Promise { - const result = await this.sql<{ max: number }[]>`SELECT MAX(number) FROM inscriptions`; + const result = await this.sql<{ max: string }[]>`SELECT MAX(number) FROM inscriptions`; if (result[0].max) { - return result[0].max; + return parseInt(result[0].max); } } @@ -407,6 +408,54 @@ export class PgStore extends BasePgStore { }; } + async getTransfersPerBlock( + args: { block_height?: number; block_hash?: string } & DbInscriptionIndexPaging + ): Promise> { + const results = await this.sql<({ total: number } & DbInscriptionLocationChange)[]>` + WITH transfers AS ( + SELECT + i.id AS inscription_id, + i.genesis_id, + i.number, + l.id AS to_id, + ( + SELECT id + FROM locations AS ll + WHERE + ll.inscription_id = i.id + AND ll.block_height < l.block_height + ORDER BY ll.block_height DESC + LIMIT 1 + ) AS from_id, + COUNT(*) OVER() as total + FROM locations AS l + INNER JOIN inscriptions AS i ON l.inscription_id = i.id + WHERE + ${ + 'block_height' in args + ? this.sql`l.block_height = ${args.block_height}` + : this.sql`l.block_hash = ${args.block_hash}` + } + AND l.genesis = FALSE + LIMIT ${args.limit} + OFFSET ${args.offset} + ) + SELECT + t.genesis_id, + t.number, + t.total, + ${this.sql.unsafe(LOCATIONS_COLUMNS.map(c => `lf.${c} AS from_${c}`).join(','))}, + ${this.sql.unsafe(LOCATIONS_COLUMNS.map(c => `lt.${c} AS to_${c}`).join(','))} + FROM transfers AS t + INNER JOIN locations AS lf ON t.from_id = lf.id + INNER JOIN locations AS lt ON t.to_id = lt.id + `; + return { + total: results[0]?.total ?? 0, + results: results ?? [], + }; + } + async getJsonContent(args: InscriptionIdentifier): Promise { const results = await this.sql` SELECT ${this.sql(JSON_CONTENTS_COLUMNS.map(c => `j.${c}`))} @@ -453,7 +502,7 @@ export class PgStore extends BasePgStore { } else { // Is this a sequential genesis insert? const maxNumber = await this.getMaxInscriptionNumber(); - if (maxNumber && maxNumber + 1 !== args.inscription.number) { + if (maxNumber !== undefined && maxNumber + 1 !== args.inscription.number) { logger.error( { block_height: args.location.block_height, diff --git a/src/pg/types.ts b/src/pg/types.ts index ce8b58bc..eb542b89 100644 --- a/src/pg/types.ts +++ b/src/pg/types.ts @@ -64,6 +64,41 @@ export type DbLocation = { current: boolean; }; +export type DbInscriptionLocationChange = { + genesis_id: string; + number: string; + from_id: string; + from_inscription_id: string; + from_block_height: string; + from_block_hash: string; + from_tx_id: string; + from_address: string | null; + from_output: string; + from_offset: string | null; + from_value: string | null; + from_sat_ordinal: string; + from_sat_rarity: string; + from_sat_coinbase_height: string; + from_timestamp: Date; + from_genesis: boolean; + from_current: boolean; + to_id: string; + to_inscription_id: string; + to_block_height: string; + to_block_hash: string; + to_tx_id: string; + to_address: string | null; + to_output: string; + to_offset: string | null; + to_value: string | null; + to_sat_ordinal: string; + to_sat_rarity: string; + to_sat_coinbase_height: string; + to_timestamp: Date; + to_genesis: boolean; + to_current: boolean; +}; + export const LOCATIONS_COLUMNS = [ 'id', 'inscription_id', diff --git a/tests/inscriptions.test.ts b/tests/inscriptions.test.ts index 9fa680cd..c84e9b15 100644 --- a/tests/inscriptions.test.ts +++ b/tests/inscriptions.test.ts @@ -405,6 +405,227 @@ describe('/inscriptions', () => { }, ]); }); + + test('shows transfers per block', async () => { + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: 775617, + hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + timestamp: 1676913207, + }) + .transaction({ + hash: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed({ + content_bytes: '0x48656C6C6F', + content_type: 'image/png', + content_length: 5, + inscription_number: 7, + inscription_fee: 2805, + inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + inscription_output_value: 10000, + inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + ordinal_number: 257418248345364, + ordinal_block_height: 650000, + ordinal_offset: 0, + satpoint_post_inscription: + '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + }) + .transaction({ + hash: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cd', + }) + .inscriptionRevealed({ + content_bytes: '0x48656C6C6F', + content_type: 'image/png', + content_length: 5, + inscription_number: 8, + inscription_fee: 2805, + inscription_id: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cdi0', + inscription_output_value: 10000, + inscriber_address: 'bc1ptrehxtus25xx8jp5pchljxg2aps7mdemc4264zzzsdcvs6q25hhsf3rrph', + ordinal_number: 257418248345364, + ordinal_block_height: 650000, + ordinal_offset: 0, + satpoint_post_inscription: + '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cd:0:0', + }) + .build() + ); + + // No transfers on this block because they are all genesis. + const response1 = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/transfers?block=775617', + }); + expect(response1.statusCode).toBe(200); + const json1 = response1.json(); + expect(json1.total).toBe(0); + expect(json1.results).toStrictEqual([]); + + // Transfers + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: 775700, + hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7bbbb', + timestamp: 1678122360, + }) + .transaction({ + hash: '0xbdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444', + }) + .inscriptionTransferred({ + inscription_number: 7, + inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + ordinal_number: 257418248345364, + updated_address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + satpoint_pre_transfer: + '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + satpoint_post_transfer: + 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0:0', + post_transfer_output_value: 9000, + }) + .transaction({ + hash: 'abe7deebd0c6bacc9b1ddd234f9442db0530180448e934f34b9cbf3d7e6d91cb', + }) + .inscriptionTransferred({ + inscription_number: 8, + inscription_id: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cdi0', + ordinal_number: 257418248345364, + updated_address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + satpoint_pre_transfer: + '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cd:0:0', + satpoint_post_transfer: + 'abe7deebd0c6bacc9b1ddd234f9442db0530180448e934f34b9cbf3d7e6d91cb:0:0', + post_transfer_output_value: 9000, + }) + .build() + ); + const response2 = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/transfers?block=775700', + }); + expect(response2.statusCode).toBe(200); + const json2 = response2.json(); + expect(json2.total).toBe(2); + expect(json2.results).toStrictEqual([ + { + id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 7, + from: { + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + block_height: 775617, + location: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + offset: '0', + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + timestamp: 1676913207000, + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + value: '10000', + }, + to: { + address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7bbbb', + block_height: 775700, + location: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0:0', + offset: '0', + output: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0', + timestamp: 1678122360000, + tx_id: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444', + value: '9000', + }, + }, + { + id: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cdi0', + number: 8, + from: { + address: 'bc1ptrehxtus25xx8jp5pchljxg2aps7mdemc4264zzzsdcvs6q25hhsf3rrph', + block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + block_height: 775617, + location: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cd:0:0', + offset: '0', + output: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cd:0', + timestamp: 1676913207000, + tx_id: '7ac73ecd01b9da4a7eab904655416dbfe8e03f193e091761b5a63ad0963570cd', + value: '10000', + }, + to: { + address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7bbbb', + block_height: 775700, + location: 'abe7deebd0c6bacc9b1ddd234f9442db0530180448e934f34b9cbf3d7e6d91cb:0:0', + offset: '0', + output: 'abe7deebd0c6bacc9b1ddd234f9442db0530180448e934f34b9cbf3d7e6d91cb:0', + timestamp: 1678122360000, + tx_id: 'abe7deebd0c6bacc9b1ddd234f9442db0530180448e934f34b9cbf3d7e6d91cb', + value: '9000', + }, + }, + ]); + + // More transfers + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: 775701, + hash: '000000000000000000044b12039abd3112963959d9fd7510ac503ea84dc17002', + timestamp: 1676913208, + }) + .transaction({ + hash: '5cabafe04aaf98b1f325b0c3ffcbff904dbdb6f3d2e9e451102fda36f1056b5e', + }) + .inscriptionTransferred({ + inscription_number: 7, + inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + ordinal_number: 257418248345364, + updated_address: 'bc1pkx5me775s748lzchytzdsw4f0lq04wssxnyk27g8fn3gee8zhjjqsn9tfp', + satpoint_pre_transfer: + 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0:0', + satpoint_post_transfer: + '5cabafe04aaf98b1f325b0c3ffcbff904dbdb6f3d2e9e451102fda36f1056b5e:0:0', + post_transfer_output_value: 8000, + }) + .build() + ); + const response3 = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/transfers?block=775701', + }); + expect(response3.statusCode).toBe(200); + const json3 = response3.json(); + expect(json3.total).toBe(1); + expect(json3.results).toStrictEqual([ + { + id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + number: 7, + from: { + address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7bbbb', + block_height: 775700, + location: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0:0', + offset: '0', + output: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0', + timestamp: 1678122360000, + tx_id: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444', + value: '9000', + }, + to: { + address: 'bc1pkx5me775s748lzchytzdsw4f0lq04wssxnyk27g8fn3gee8zhjjqsn9tfp', + block_hash: '000000000000000000044b12039abd3112963959d9fd7510ac503ea84dc17002', + block_height: 775701, + location: '5cabafe04aaf98b1f325b0c3ffcbff904dbdb6f3d2e9e451102fda36f1056b5e:0:0', + offset: '0', + output: '5cabafe04aaf98b1f325b0c3ffcbff904dbdb6f3d2e9e451102fda36f1056b5e:0', + timestamp: 1676913208000, + tx_id: '5cabafe04aaf98b1f325b0c3ffcbff904dbdb6f3d2e9e451102fda36f1056b5e', + value: '8000', + }, + }, + ]); + }); }); describe('index', () => {