feat(brc20): add transferable-inscriptions endpoint

This commit is contained in:
ASuciuX
2025-03-21 20:24:15 +02:00
committed by GitHub
parent 6fdd83ac02
commit 60d2a40d22
6 changed files with 598 additions and 3 deletions

View File

@@ -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();
};

View File

@@ -487,6 +487,19 @@ export const Brc20ActivityResponseSchema = Type.Object({
});
export type Brc20ActivityResponse = Static<typeof Brc20ActivityResponseSchema>;
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({

View File

@@ -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;

View File

@@ -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<DbPaginatedResult<DbBrc20TransferableInscription>> {
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<DbBrc20TokenWithSupply | undefined> {
const result = await this.sql<DbBrc20TokenWithSupply[]>`
WITH token AS (

View File

@@ -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;
};

View File

@@ -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([]);
});
});
});