From 60d2a40d2277550fbeb432fafa63e91fd3f894a5 Mon Sep 17 00:00:00 2001 From: ASuciuX <151519329+ASuciuX@users.noreply.github.com> Date: Fri, 21 Mar 2025 20:24:15 +0200 Subject: [PATCH] feat(brc20): add transferable-inscriptions endpoint --- api/ordinals/src/api/routes/brc20.ts | 46 ++ api/ordinals/src/api/schemas.ts | 13 + api/ordinals/src/api/util/helpers.ts | 14 + api/ordinals/src/pg/brc20/brc20-pg-store.ts | 39 +- api/ordinals/src/pg/brc20/types.ts | 8 + api/ordinals/tests/brc-20/api.test.ts | 481 +++++++++++++++++++- 6 files changed, 598 insertions(+), 3 deletions(-) diff --git a/api/ordinals/src/api/routes/brc20.ts b/api/ordinals/src/api/routes/brc20.ts index e6685bb..d36d300 100644 --- a/api/ordinals/src/api/routes/brc20.ts +++ b/api/ordinals/src/api/routes/brc20.ts @@ -19,6 +19,7 @@ import { NotFoundResponse, OffsetParam, PaginatedResponse, + Brc20TransferableInscriptionsResponseSchema, } from '../schemas'; import { handleInscriptionTransfersCache } from '../util/cache'; import { @@ -28,6 +29,7 @@ import { parseBrc20Holders, parseBrc20Supply, parseBrc20Tokens, + parseBrc20TransferableInscriptions, } from '../util/helpers'; export const Brc20Routes: FastifyPluginCallback< @@ -237,5 +239,49 @@ export const Brc20Routes: FastifyPluginCallback< } ); + fastify.get( + '/brc-20/balances/:address/transferable', + { + schema: { + operationId: 'getBrc20TransferableInscriptions', + summary: 'BRC-20 Transferable Inscriptions', + description: 'Retrieves BRC-20 transferable inscriptions for a Bitcoin address', + tags: ['BRC-20'], + params: Type.Object({ + address: AddressParam, + }), + querystring: Type.Object({ + ticker: Type.Optional(Brc20TickersParam), + // Pagination + offset: Type.Optional(OffsetParam), + limit: Type.Optional(LimitParam), + }), + response: { + 200: PaginatedResponse( + Brc20TransferableInscriptionsResponseSchema, + 'Paginated BRC-20 Transferable Inscriptions Response' + ), + }, + }, + }, + async (request, reply) => { + const limit = request.query.limit ?? DEFAULT_API_LIMIT; + const offset = request.query.offset ?? 0; + const balances = await fastify.brc20Db.getTransferableInscriptions({ + limit, + offset, + address: request.params.address, + ticker: request.query.ticker, + }); + + await reply.send({ + limit, + offset, + total: balances.total, + results: parseBrc20TransferableInscriptions(balances.results), + }); + } + ); + done(); }; diff --git a/api/ordinals/src/api/schemas.ts b/api/ordinals/src/api/schemas.ts index 85249a2..3021699 100644 --- a/api/ordinals/src/api/schemas.ts +++ b/api/ordinals/src/api/schemas.ts @@ -487,6 +487,19 @@ export const Brc20ActivityResponseSchema = Type.Object({ }); export type Brc20ActivityResponse = Static; +export const Brc20TransferableInscriptionsResponseSchema = Type.Object({ + inscription_number: Type.Integer({ examples: [1095397] }), + inscription_id: Type.String({ + examples: ['ffb9df4532e05fe514765bcbeebb75c38d8f1774a291f9d6f123d68820cc39cdi0'], + }), + ordinal_number: Type.String({ examples: ['300000'] }), + amount: Type.String({ examples: ['30000000000000000000'] }), + ticker: Type.String({ examples: ['meme'] }), +}); +export type Brc20TransferableInscriptionsResponse = Static< + typeof Brc20TransferableInscriptionsResponseSchema +>; + export const Brc20TokenResponseSchema = Type.Object( { id: Type.String({ diff --git a/api/ordinals/src/api/util/helpers.ts b/api/ordinals/src/api/util/helpers.ts index e10fae8..54eefef 100644 --- a/api/ordinals/src/api/util/helpers.ts +++ b/api/ordinals/src/api/util/helpers.ts @@ -6,6 +6,7 @@ import { DbBrc20Holder, DbBrc20Token, DbBrc20TokenWithSupply, + DbBrc20TransferableInscription, } from '../../pg/brc20/types'; import { DbFullyLocatedInscriptionResult, @@ -21,6 +22,7 @@ import { Brc20HolderResponse, Brc20Supply, Brc20TokenResponse, + Brc20TransferableInscriptionsResponse, InscriptionLocationResponse, InscriptionResponseType, } from '../schemas'; @@ -244,6 +246,18 @@ export function parseBrc20Holders(items: DbBrc20Holder[]): Brc20HolderResponse[] })); } +export function parseBrc20TransferableInscriptions( + items: DbBrc20TransferableInscription[] +): Brc20TransferableInscriptionsResponse[] { + return items.map(i => ({ + inscription_number: parseInt(i.inscription_number), + inscription_id: i.inscription_id, + amount: i.amount, + ticker: i.ticker, + ordinal_number: i.ordinal_number, + })); +} + export function parseSatPoint(satpoint: string): { tx_id: string; vout: string; diff --git a/api/ordinals/src/pg/brc20/brc20-pg-store.ts b/api/ordinals/src/pg/brc20/brc20-pg-store.ts index 4afea53..c06e145 100644 --- a/api/ordinals/src/pg/brc20/brc20-pg-store.ts +++ b/api/ordinals/src/pg/brc20/brc20-pg-store.ts @@ -6,6 +6,7 @@ import { DbBrc20Holder, DbBrc20Token, DbBrc20TokenWithSupply, + DbBrc20TransferableInscription, } from './types'; import { Brc20TokenOrderBy } from '../../api/schemas'; import { objRemoveUndefinedValues } from '../helpers'; @@ -88,13 +89,13 @@ export class Brc20PgStore extends BasePgStore { const results = await this.sql<(DbBrc20Balance & { total: number })[]>` SELECT b.ticker, (SELECT decimals FROM tokens WHERE ticker = b.ticker) AS decimals, - b.avail_balance, b.trans_balance, b.total_balance, COUNT(*) OVER() as total + b.avail_balance, b.trans_balance, b.total_balance, COUNT(*) OVER() AS total ${ args.block_height ? this.sql` FROM balances_history b INNER JOIN ( - SELECT ticker, address, MAX(block_height) as max_block_height + SELECT ticker, address, MAX(block_height) AS max_block_height FROM balances_history WHERE address = ${args.address} AND block_height <= ${args.block_height} GROUP BY ticker, address @@ -121,6 +122,40 @@ export class Brc20PgStore extends BasePgStore { }; } + async getTransferableInscriptions( + args: { + address: string; + ticker?: string[]; + } & DbInscriptionIndexPaging + ): Promise> { + const results = await this.sql<(DbBrc20TransferableInscription & { total: number })[]>` + SELECT + o1.inscription_number, o1.inscription_id, o1.ordinal_number, o1.amount, o1.ticker, COUNT(*) OVER() AS total + FROM operations AS o1 + WHERE o1.operation = 'transfer' + AND o1.address = ${args.address} + ${ + args.ticker + ? this.sql`AND LOWER(o1.ticker) IN (${args.ticker.map(t => t.toLowerCase())})` + : this.sql`` + } + AND NOT EXISTS ( + SELECT 1 + FROM operations o2 + WHERE o2.inscription_id = o1.inscription_id + AND o2.operation = 'transfer_send' + ) + ORDER BY o1.block_height DESC, o1.tx_index DESC + LIMIT ${args.limit} + OFFSET ${args.offset} + `; + + return { + total: results[0]?.total ?? 0, + results: results ?? [], + }; + } + async getToken(args: { ticker: string }): Promise { const result = await this.sql` WITH token AS ( diff --git a/api/ordinals/src/pg/brc20/types.ts b/api/ordinals/src/pg/brc20/types.ts index a8feeca..4906063 100644 --- a/api/ordinals/src/pg/brc20/types.ts +++ b/api/ordinals/src/pg/brc20/types.ts @@ -63,3 +63,11 @@ export type DbBrc20Activity = { deploy_max: string; deploy_limit: string | null; }; + +export type DbBrc20TransferableInscription = { + inscription_number: string; + inscription_id: string; + amount: string; + ticker: string; + ordinal_number: string; +}; diff --git a/api/ordinals/tests/brc-20/api.test.ts b/api/ordinals/tests/brc-20/api.test.ts index e018914..4c50151 100644 --- a/api/ordinals/tests/brc-20/api.test.ts +++ b/api/ordinals/tests/brc-20/api.test.ts @@ -1,5 +1,9 @@ import { buildApiServer } from '../../src/api/init'; -import { Brc20TokenResponse, Brc20ActivityResponse } from '../../src/api/schemas'; +import { + Brc20TokenResponse, + Brc20ActivityResponse, + Brc20TransferableInscriptionsResponse, +} from '../../src/api/schemas'; import { Brc20PgStore } from '../../src/pg/brc20/brc20-pg-store'; import { PgStore } from '../../src/pg/pg-store'; import { @@ -1627,4 +1631,479 @@ describe('BRC-20 API', () => { ); }); }); + + describe('/brc-20/balances/:address/transferable', () => { + test('returns 202 if address has no transferable inscriptions', async () => { + const response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td/transferable`, + }); + expect(response.statusCode).toBe(200); + const json = response.json(); + expect(json.total).toBe(0); + expect(json.results).toEqual([]); + }); + + test('transferable inscriptions are accurate', async () => { + // This test verifies that the transferable inscriptions API correctly tracks BRC-20 tokens + // that are available for transfer by an address. The test follows this flow: + // 1. Address A deploys two tokens: 'pepe' and 'meme' + // 2. Address A mints 10000 'pepe' and 20000 'meme' + // 3. Address B mints 10000 'pepe' + // 4. Address A creates a transfer inscription for 9000 'pepe' + // 5. Verify that A's transferable inscriptions include this 'pepe' transfer + // 6. A sends the 'pepe' transfer inscription to B + // 7. Verify that A no longer has any transferable inscriptions + // 8. A creates transfer inscriptions for 500 'pepe' and 2000 'meme' + // 9. Verify that A has both transferable inscriptions + // 11. Verify that A only has the 'meme' transferable inscription if querying for 'meme' ticker + // 12. Verify that A only has the 'pepe' transferable inscription if querying for 'pepe' ticker + // 13. Verify that A only has no transferable inscription if querying for 'rere' ticker + // 14. A sends the 500 'pepe' transfer to B + // 15. Verify that A only has the 'meme' transferable inscription left + // 16. A sends the 2000 'meme' transfer to B + // 17. Verify that A has no transferable inscriptions left + + // Setup + const blockHeights = incrementing(767430); + const numbers = incrementing(0); + const addressA = 'bc1q6uwuet65rm6xvlz7ztw2gvdmmay5uaycu03mqz'; + const addressB = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4'; + + // A deploys pepe + let transferHash = randomHash(); + let blockHash = randomHash(); + await brc20TokenDeploy(brc20Db.sql, { + ticker: 'pepe', + display_ticker: 'pepe', + inscription_id: `${transferHash}i0`, + inscription_number: numbers.next().value.toString(), + block_height: blockHeights.next().value.toString(), + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + address: addressA, + max: '21000000000000000000000000', + limit: '21000000000000000000000000', + decimals: 18, + self_mint: false, + minted_supply: '0', + tx_count: 1, + timestamp: 1677803510, + operation: 'deploy', + ordinal_number: '20000', + output: `${transferHash}:0`, + offset: '0', + to_address: null, + amount: '0', + }); + + // A deploys meme + transferHash = randomHash(); + blockHash = randomHash(); + await brc20TokenDeploy(brc20Db.sql, { + ticker: 'meme', + display_ticker: 'meme', + inscription_id: `${transferHash}i0`, + inscription_number: numbers.next().value.toString(), + block_height: blockHeights.next().value.toString(), + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + address: addressA, + max: '21000000000000000000000000', + limit: '21000000000000000000000000', + decimals: 18, + self_mint: false, + minted_supply: '0', + tx_count: 1, + timestamp: 1677803510, + operation: 'deploy', + ordinal_number: '30000', + output: `${transferHash}:0`, + offset: '0', + to_address: null, + amount: '0', + }); + + // A mints 10000 pepe + transferHash = randomHash(); + blockHash = randomHash(); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'mint', + inscription_id: `${transferHash}i0`, + inscription_number: numbers.next().value.toString(), + ordinal_number: '200000', + block_height: blockHeights.next().value.toString(), + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: null, + amount: '10000000000000000000000', + }); + + // A mints 20000 meme + transferHash = randomHash(); + blockHash = randomHash(); + await brc20Operation(brc20Db.sql, { + ticker: 'meme', + operation: 'mint', + inscription_id: `${transferHash}i0`, + inscription_number: numbers.next().value.toString(), + ordinal_number: '300000', + block_height: blockHeights.next().value.toString(), + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: null, + amount: '20000000000000000000000', + }); + + // B mints 10000 pepe + transferHash = randomHash(); + blockHash = randomHash(); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'mint', + inscription_id: `${transferHash}i0`, + inscription_number: numbers.next().value.toString(), + ordinal_number: '200000', + block_height: blockHeights.next().value.toString(), + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressB, + to_address: null, + amount: '10000000000000000000000', + }); + + // A creates transfer of 9000 pepe + blockHash = randomHash(); + const inscriptionNumberMocked = numbers.next().value.toString(); + const blockHeightMocked = blockHeights.next().value.toString(); + const transferHashMocked = randomHash(); + transferHash = transferHashMocked; + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer', + inscription_id: `${transferHashMocked}i0`, + inscription_number: inscriptionNumberMocked, + ordinal_number: '200000', + block_height: blockHeightMocked, + block_hash: blockHash, + tx_id: transferHashMocked, + tx_index: 0, + output: `${transferHashMocked}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: null, + amount: '9000000000000000000000', + }); + + let response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable`, + }); + + expect(response.statusCode).toBe(200); + let json = response.json(); + expect(json.total).toBe(1); + expect(json.results).toEqual([ + { + inscription_id: `${transferHash}i0`, + ticker: 'pepe', + inscription_number: parseInt(inscriptionNumberMocked), + ordinal_number: '200000', + amount: '9000000000000000000000', + } as Brc20TransferableInscriptionsResponse, + ]); + + // A sends transfer inscription to B (aka transfer/sale) + transferHash = randomHash(); + blockHash = randomHash(); + const blockHeightMockedSend = blockHeights.next().value.toString(); + const inscriptionNumberMockedSend = numbers.next().value.toString(); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer_send', + inscription_id: `${transferHashMocked}i0`, + inscription_number: inscriptionNumberMockedSend, + ordinal_number: '200000', + block_height: blockHeightMockedSend, + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: addressB, + amount: '9000000000000000000000', + }); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer_receive', + inscription_id: `${transferHashMocked}i0`, + inscription_number: inscriptionNumberMockedSend, + ordinal_number: '200000', + block_height: blockHeightMockedSend, + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressB, + to_address: null, + amount: '9000000000000000000000', + }); + + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable`, + }); + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(0); + expect(json.results).toEqual([]); + + // A creates transfer of 500 pepe and 2000 meme + blockHash = randomHash(); + const inscriptionNumberMocked2 = numbers.next().value.toString(); + const blockHeightMocked2 = blockHeights.next().value.toString(); + const transferHashMocked2 = randomHash(); + const inscriptionNumberMocked3 = numbers.next().value.toString(); + const blockHeightMocked3 = blockHeights.next().value.toString(); + const transferHashMocked3 = randomHash(); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer', + inscription_id: `${transferHashMocked2}i0`, + inscription_number: inscriptionNumberMocked2, + ordinal_number: '200000', + block_height: blockHeightMocked2, + block_hash: blockHash, + tx_id: transferHashMocked2, + tx_index: 0, + output: `${transferHashMocked2}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: null, + amount: '500000000000000000000', + }); + + await brc20Operation(brc20Db.sql, { + ticker: 'meme', + operation: 'transfer', + inscription_id: `${transferHashMocked3}i0`, + inscription_number: inscriptionNumberMocked3, + ordinal_number: '300000', + block_height: blockHeightMocked3, + block_hash: blockHash, + tx_id: transferHashMocked3, + tx_index: 0, + output: `${transferHashMocked3}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: null, + amount: '2000000000000000000000', + }); + + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable`, + }); + + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(2); + expect(json.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inscription_id: `${transferHashMocked2}i0`, + ticker: 'pepe', + inscription_number: parseInt(inscriptionNumberMocked2), + ordinal_number: '200000', + amount: '500000000000000000000', + } as Brc20TransferableInscriptionsResponse), + expect.objectContaining({ + inscription_id: `${transferHashMocked3}i0`, + ticker: 'meme', + inscription_number: parseInt(inscriptionNumberMocked3), + ordinal_number: '300000', + amount: '2000000000000000000000', + } as Brc20TransferableInscriptionsResponse), + ]) + ); + + // Verify that A only has the 'meme' transferable inscription if querying for 'meme' ticker + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable?ticker=meme`, + }); + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(1); + expect(json.results).toEqual([ + expect.objectContaining({ + inscription_id: `${transferHashMocked3}i0`, + ticker: 'meme', + inscription_number: parseInt(inscriptionNumberMocked3), + ordinal_number: '300000', + amount: '2000000000000000000000', + } as Brc20TransferableInscriptionsResponse), + ]); + + // Verify that A only has the 'pepe' transferable inscription if querying for 'pepe' ticker + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable?ticker=pepe`, + }); + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(1); + expect(json.results).toEqual([ + expect.objectContaining({ + inscription_id: `${transferHashMocked2}i0`, + ticker: 'pepe', + inscription_number: parseInt(inscriptionNumberMocked2), + ordinal_number: '200000', + amount: '500000000000000000000', + } as Brc20TransferableInscriptionsResponse), + ]); + + // Verify that A only has no transferable inscription if querying for 'rere' ticker + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable?ticker=rere`, + }); + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(0); + expect(json.results).toEqual([]); + + // A sends transfer inscription to B (aka transfer/sale) of pepe + transferHash = randomHash(); + blockHash = randomHash(); + const blockHeightMockedSend4 = blockHeights.next().value.toString(); + const inscriptionNumberMockedSend4 = numbers.next().value.toString(); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer_send', + inscription_id: `${transferHashMocked2}i0`, + inscription_number: inscriptionNumberMockedSend4, + ordinal_number: '200000', + block_height: blockHeightMockedSend4, + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: addressB, + amount: '500000000000000000000', + }); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer_receive', + inscription_id: `${transferHashMocked2}i0`, + inscription_number: inscriptionNumberMockedSend4, + ordinal_number: '200000', + block_height: blockHeightMockedSend4, + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressB, + to_address: null, + amount: '500000000000000000000', + }); + + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable`, + }); + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(1); + expect(json.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inscription_id: `${transferHashMocked3}i0`, + ticker: 'meme', + inscription_number: parseInt(inscriptionNumberMocked3), + ordinal_number: '300000', + amount: '2000000000000000000000', + } as Brc20TransferableInscriptionsResponse), + ]) + ); + + // A sends transfer inscription to B (aka transfer/sale) of meme + transferHash = randomHash(); + blockHash = randomHash(); + const blockHeightMockedSend5 = blockHeights.next().value.toString(); + const inscriptionNumberMockedSend5 = numbers.next().value.toString(); + await brc20Operation(brc20Db.sql, { + ticker: 'pepe', + operation: 'transfer_send', + inscription_id: `${transferHashMocked3}i0`, + inscription_number: inscriptionNumberMockedSend5, + ordinal_number: '300000', + block_height: blockHeightMockedSend5, + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressA, + to_address: addressB, + amount: '2000000000000000000000', + }); + await brc20Operation(brc20Db.sql, { + ticker: 'meme', + operation: 'transfer_receive', + inscription_id: `${transferHashMocked3}i0`, + inscription_number: inscriptionNumberMockedSend5, + ordinal_number: '300000', + block_height: blockHeightMockedSend5, + block_hash: blockHash, + tx_id: transferHash, + tx_index: 0, + output: `${transferHash}:0`, + offset: '0', + timestamp: 1677803510, + address: addressB, + to_address: null, + amount: '2000000000000000000000', + }); + + response = await fastify.inject({ + method: 'GET', + url: `/ordinals/brc-20/balances/${addressA}/transferable`, + }); + expect(response.statusCode).toBe(200); + json = response.json(); + expect(json.total).toBe(0); + expect(json.results).toEqual([]); + }); + }); });