diff --git a/migrations/1688925112931_genesis-locations.ts b/migrations/1688925112931_genesis-locations.ts new file mode 100644 index 00000000..6cc69a11 --- /dev/null +++ b/migrations/1688925112931_genesis-locations.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +export function up(pgm: MigrationBuilder): void { + pgm.createTable('genesis', { + inscription_id: { + type: 'bigint', + notNull: true, + }, + location_id: { + type: 'bigint', + notNull: true, + }, + block_height: { + type: 'bigint', + notNull: true, + }, + }); + pgm.createConstraint('genesis', 'genesis_inscription_id_unique', 'UNIQUE(inscription_id)'); + pgm.createIndex('genesis', ['location_id']); + + pgm.createTable('current', { + inscription_id: { + type: 'bigint', + notNull: true, + }, + location_id: { + type: 'bigint', + notNull: true, + }, + block_height: { + type: 'bigint', + notNull: true, + }, + }); + pgm.createConstraint('current', 'current_inscription_id_unique', 'UNIQUE(inscription_id)'); + pgm.createIndex('current', ['location_id']); +} diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 24660aa3..4d9e0c1a 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -227,8 +227,6 @@ export class PgStore extends BasePgStore { await this.refreshMaterializedView('chain_tip'); // Skip expensive view refreshes if we're not streaming any live blocks yet. if (payload.chainhook.is_streaming_blocks) { - await this.refreshMaterializedView('genesis_locations'); - await this.refreshMaterializedView('current_locations'); await this.refreshMaterializedView('inscription_count'); await this.refreshMaterializedView('mime_type_counts'); await this.refreshMaterializedView('sat_rarity_counts'); @@ -346,6 +344,14 @@ export class PgStore extends BasePgStore { // `ORDER` statement const order = sort?.order === Order.asc ? sql`ASC` : sql`DESC`; const results = await sql<({ total: number } & DbFullyLocatedInscriptionResult)[]>` + WITH gen_locations AS ( + SELECT l.* FROM locations AS l + INNER JOIN genesis AS g ON l.id = g.location_id + ), + cur_locations AS ( + SELECT l.* FROM locations AS l + INNER JOIN current AS c ON l.id = c.location_id + ) SELECT i.genesis_id, i.number, @@ -374,8 +380,8 @@ export class PgStore extends BasePgStore { : sql`0 as total` } FROM inscriptions AS i - INNER JOIN current_locations AS loc ON loc.inscription_id = i.id - INNER JOIN genesis_locations AS gen ON gen.inscription_id = i.id + INNER JOIN cur_locations AS loc ON loc.inscription_id = i.id + INNER JOIN gen_locations AS gen ON gen.inscription_id = i.id WHERE TRUE ${ filters?.genesis_id?.length @@ -523,7 +529,7 @@ export class PgStore extends BasePgStore { FROM locations AS l INNER JOIN inscriptions AS i ON l.inscription_id = i.id WHERE - NOT EXISTS (SELECT id FROM genesis_locations WHERE id = l.id) + NOT EXISTS (SELECT location_id FROM genesis WHERE location_id = l.id) AND ${ 'block_height' in args @@ -660,7 +666,7 @@ export class PgStore extends BasePgStore { sat_coinbase_height: args.location.sat_coinbase_height, timestamp: sql`to_timestamp(${args.location.timestamp})`, }; - await sql` + const locationRes = await sql<{ id: string }[]>` INSERT INTO locations ${sql(location)} ON CONFLICT ON CONSTRAINT locations_output_offset_unique DO UPDATE SET inscription_id = EXCLUDED.inscription_id, @@ -673,7 +679,13 @@ export class PgStore extends BasePgStore { sat_rarity = EXCLUDED.sat_rarity, sat_coinbase_height = EXCLUDED.sat_coinbase_height, timestamp = EXCLUDED.timestamp + RETURNING id `; + await this.updateInscriptionLocationPointers({ + inscription_id, + location_id: locationRes[0].id, + block_height: args.location.block_height, + }); const json = inscriptionContentToJson(args.inscription); if (json) { const values = { @@ -698,36 +710,44 @@ export class PgStore extends BasePgStore { inscription_id: number; location: DbLocationInsert; }): Promise { - const location = { - inscription_id: args.inscription_id, - block_height: args.location.block_height, - block_hash: args.location.block_hash, - tx_id: args.location.tx_id, - address: args.location.address, - output: args.location.output, - offset: args.location.offset, - prev_output: args.location.prev_output, - prev_offset: args.location.prev_offset, - value: args.location.value, - sat_ordinal: args.location.sat_ordinal, - sat_rarity: args.location.sat_rarity, - sat_coinbase_height: args.location.sat_coinbase_height, - timestamp: this.sql`to_timestamp(${args.location.timestamp})`, - }; - await this.sql` - INSERT INTO locations ${this.sql(location)} - ON CONFLICT ON CONSTRAINT locations_output_offset_unique DO UPDATE SET - inscription_id = EXCLUDED.inscription_id, - block_height = EXCLUDED.block_height, - block_hash = EXCLUDED.block_hash, - tx_id = EXCLUDED.tx_id, - address = EXCLUDED.address, - value = EXCLUDED.value, - sat_ordinal = EXCLUDED.sat_ordinal, - sat_rarity = EXCLUDED.sat_rarity, - sat_coinbase_height = EXCLUDED.sat_coinbase_height, - timestamp = EXCLUDED.timestamp - `; + await this.sqlWriteTransaction(async sql => { + const location = { + inscription_id: args.inscription_id, + block_height: args.location.block_height, + block_hash: args.location.block_hash, + tx_id: args.location.tx_id, + address: args.location.address, + output: args.location.output, + offset: args.location.offset, + prev_output: args.location.prev_output, + prev_offset: args.location.prev_offset, + value: args.location.value, + sat_ordinal: args.location.sat_ordinal, + sat_rarity: args.location.sat_rarity, + sat_coinbase_height: args.location.sat_coinbase_height, + timestamp: this.sql`to_timestamp(${args.location.timestamp})`, + }; + const locationRes = await sql<{ id: string }[]>` + INSERT INTO locations ${this.sql(location)} + ON CONFLICT ON CONSTRAINT locations_output_offset_unique DO UPDATE SET + inscription_id = EXCLUDED.inscription_id, + block_height = EXCLUDED.block_height, + block_hash = EXCLUDED.block_hash, + tx_id = EXCLUDED.tx_id, + address = EXCLUDED.address, + value = EXCLUDED.value, + sat_ordinal = EXCLUDED.sat_ordinal, + sat_rarity = EXCLUDED.sat_rarity, + sat_coinbase_height = EXCLUDED.sat_coinbase_height, + timestamp = EXCLUDED.timestamp + RETURNING id + `; + await this.updateInscriptionLocationPointers({ + inscription_id: args.inscription_id, + location_id: locationRes[0].id, + block_height: args.location.block_height, + }); + }); } private async rollBackInscriptionGenesis(args: { genesis_id: string }): Promise { @@ -759,4 +779,27 @@ export class PgStore extends BasePgStore { }); return inscription_id; } + + private async updateInscriptionLocationPointers(args: { + inscription_id: number; + location_id: string; + block_height: number; + }): Promise { + await this.sqlWriteTransaction(async sql => { + await sql` + INSERT INTO genesis ${sql(args)} + ON CONFLICT ON CONSTRAINT genesis_inscription_id_unique DO UPDATE SET + location_id = EXCLUDED.location_id, + block_height = EXCLUDED.block_height + WHERE EXCLUDED.block_height < genesis.block_height + `; + await sql` + INSERT INTO current ${sql(args)} + ON CONFLICT ON CONSTRAINT current_inscription_id_unique DO UPDATE SET + location_id = EXCLUDED.location_id, + block_height = EXCLUDED.block_height + WHERE EXCLUDED.block_height > current.block_height + `; + }); + } } diff --git a/tests/inscriptions.test.ts b/tests/inscriptions.test.ts index 38b580c3..91d03ebc 100644 --- a/tests/inscriptions.test.ts +++ b/tests/inscriptions.test.ts @@ -392,6 +392,117 @@ describe('/inscriptions', () => { }); }); + test('shows correct inscription data after an unordered transfer', async () => { + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ + height: 775617, + hash: '0x00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + timestamp: 1676913207, + }) + .transaction({ + hash: '0x38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + }) + .inscriptionRevealed({ + content_bytes: '0x48656C6C6F', + content_type: 'image/png', + content_length: 5, + inscription_number: 7, + inscription_fee: 2805, + inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + inscription_output_value: 9000, + inscriber_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + ordinal_number: 257418248345364, + ordinal_block_height: 51483, + ordinal_offset: 0, + satpoint_post_inscription: + '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + }) + .build() + ); + + const response1 = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + }); + expect(response1.statusCode).toBe(200); + expect(response1.json()).toStrictEqual({ + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + genesis_address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + genesis_block_hash: '00000000000000000002a90330a99f67e3f01eb2ce070b45930581e82fb7a91d', + genesis_block_height: 775617, + content_length: 5, + mime_type: 'image/png', + content_type: 'image/png', + genesis_fee: '2805', + id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + offset: '0', + number: 7, + value: '9000', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + sat_ordinal: '257418248345364', + sat_coinbase_height: 51483, + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + location: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + sat_rarity: 'common', + timestamp: 1676913207000, + genesis_timestamp: 1676913207000, + genesis_tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + curse_type: null, + }); + + // Insert real genesis + await db.updateInscriptions( + new TestChainhookPayloadBuilder() + .apply() + .block({ height: 775610, timestamp: 1678122360 }) + .transaction({ + hash: '0xbdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444', + }) + .inscriptionTransferred({ + inscription_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + updated_address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + satpoint_pre_transfer: + '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + satpoint_post_transfer: + 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444:0:0', + post_transfer_output_value: 9000, + ordinal_number: null, + }) + .build() + ); + const response = await fastify.inject({ + method: 'GET', + url: '/ordinals/v1/inscriptions/38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toStrictEqual({ + address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td', + genesis_address: 'bc1p3xqwzmddceqrd6x9yxplqzkl5vucta2gqm5szpkmpuvcvgs7g8psjf8htd', + genesis_block_hash: '163de66dc9c0949905bfe8e148bde04600223cf88d19f26fdbeba1d6e6fa0f88', + genesis_block_height: 775610, + content_length: 5, + mime_type: 'image/png', + content_type: 'image/png', + genesis_fee: '2805', + id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dci0', + offset: '0', + number: 7, + value: '9000', + tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc', + sat_ordinal: '257418248345364', + sat_coinbase_height: 51483, + output: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0', + location: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc:0:0', + sat_rarity: 'common', + timestamp: 1676913207000, + genesis_timestamp: 1678122360000, + genesis_tx_id: 'bdda0d240132bab2af7f797d1507beb1acab6ad43e2c0ef7f96291aea5cc3444', + curse_type: null, + }); + }); + test('shows correct cursed inscription data after a transfer', async () => { await db.updateInscriptions( new TestChainhookPayloadBuilder() @@ -2422,7 +2533,7 @@ describe('/inscriptions', () => { expect(response1.statusCode).toBe(200); const responseJson1 = response1.json(); expect(responseJson1.total).toBe(0); - expect(responseJson1.results.length).toBe(0); + expect(responseJson1.results.length).toBeGreaterThan(0); const response2 = await fastify.inject({ method: 'GET', @@ -2431,7 +2542,7 @@ describe('/inscriptions', () => { expect(response2.statusCode).toBe(200); const responseJson2 = response2.json(); expect(responseJson2.total).toBe(0); - expect(responseJson2.results.length).toBe(0); + expect(responseJson2.results.length).toBeGreaterThan(0); const response3 = await fastify.inject({ method: 'GET', @@ -2440,7 +2551,7 @@ describe('/inscriptions', () => { expect(response3.statusCode).toBe(200); const responseJson3 = response3.json(); expect(responseJson3.total).toBe(0); - expect(responseJson3.results.length).toBe(0); + expect(responseJson3.results.length).toBeGreaterThan(0); }); }); });