Skip to content

Commit

Permalink
feat: first balance endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelcr committed May 17, 2023
1 parent 7098298 commit f9c6654
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 36 deletions.
2 changes: 2 additions & 0 deletions src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PgStore } from '../pg/pg-store';
import { SatRoutes } from './routes/sats';
import { StatusRoutes } from './routes/status';
import FastifyMetrics from 'fastify-metrics';
import { Brc20Routes } from './routes/brc20';

export const Api: FastifyPluginAsync<
Record<never, never>,
Expand All @@ -17,6 +18,7 @@ export const Api: FastifyPluginAsync<
await fastify.register(StatusRoutes);
await fastify.register(InscriptionsRoutes);
await fastify.register(SatRoutes);
await fastify.register(Brc20Routes);
};

export async function buildApiServer(args: { db: PgStore }) {
Expand Down
59 changes: 59 additions & 0 deletions src/api/routes/brc20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import { Type } from '@sinclair/typebox';
import { FastifyPluginCallback } from 'fastify';
import { Server } from 'http';
import {
AddressParam,
Brc20BalanceResponseSchema,
Brc20TickersParam,
LimitParam,
OffsetParam,
PaginatedResponse,
} from '../schemas';
import { DEFAULT_API_LIMIT, parseBrc20Balances } from '../util/helpers';

export const Brc20Routes: FastifyPluginCallback<
Record<never, never>,
Server,
TypeBoxTypeProvider
> = (fastify, options, done) => {
fastify.get(
'/brc-20/balances',
{
schema: {
operationId: 'getBrc20Balances',
summary: 'BRC-20 Balances',
description: 'Retrieves BRC-20 token balances for a Bitcoin address',
tags: ['BRC-20'],
querystring: Type.Object({
address: AddressParam,
ticker: Type.Optional(Brc20TickersParam),
// Pagination
offset: Type.Optional(OffsetParam),
limit: Type.Optional(LimitParam),
}),
response: {
200: PaginatedResponse(Brc20BalanceResponseSchema, 'Paginated BRC-20 Balance Response'),
},
},
},
async (request, reply) => {
const limit = request.query.limit ?? DEFAULT_API_LIMIT;
const offset = request.query.offset ?? 0;
const balances = await fastify.db.getBrc20Balances({
limit,
offset,
address: request.query.address,
ticker: request.query.ticker,
});
await reply.send({
limit,
offset,
total: balances.total,
results: parseBrc20Balances(balances.results),
});
}
);

done();
};
14 changes: 14 additions & 0 deletions src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const OpenApiSchemaOptions: SwaggerOptions = {
name: 'Satoshis',
description: 'Endpoints to query Satoshi ordinal and rarity information',
},
{
name: 'BRC-20',
description: 'Endpoints to query BRC-20 token balances and events',
},
],
},
};
Expand Down Expand Up @@ -57,6 +61,8 @@ export const AddressesParam = Type.Array(AddressParam, {
],
});

export const Brc20TickersParam = Type.Array(Type.String());

export const InscriptionIdParam = Type.RegEx(/^[a-fA-F0-9]{64}i[0-9]+$/, {
title: 'Inscription ID',
description: 'Inscription ID',
Expand Down Expand Up @@ -323,6 +329,14 @@ export const BlockInscriptionTransferSchema = Type.Object({
});
export type BlockInscriptionTransfer = Static<typeof BlockInscriptionTransferSchema>;

export const Brc20BalanceResponseSchema = Type.Object({
ticker: Type.String({ examples: ['PEPE'] }),
available_balance: Type.String({ examples: ['1500.00000'] }),
transferrable_balance: Type.String({ examples: ['500.00000'] }),
overall_balance: Type.String({ examples: ['2000.00000'] }),
});
export type Brc20BalanceResponse = Static<typeof Brc20BalanceResponseSchema>;

export const NotFoundResponse = Type.Object(
{
error: Type.Literal('Not found'),
Expand Down
12 changes: 12 additions & 0 deletions src/api/util/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import BigNumber from 'bignumber.js';
import {
DbBrc20Balance,
DbFullyLocatedInscriptionResult,
DbInscriptionLocationChange,
DbLocation,
} from '../../pg/types';
import {
BlockInscriptionTransfer,
Brc20BalanceResponse,
InscriptionLocationResponse,
InscriptionResponseType,
} from '../schemas';
Expand Down Expand Up @@ -87,6 +90,15 @@ export function parseBlockTransfers(
}));
}

export function parseBrc20Balances(items: DbBrc20Balance[]): Brc20BalanceResponse[] {
return items.map(i => ({
ticker: i.ticker,
available_balance: i.avail_balance,
transferrable_balance: i.trans_balance,
overall_balance: BigNumber(i.avail_balance).plus(i.trans_balance).toString(),
}));
}

/**
* Decodes a `0x` prefixed hex string to a buffer.
* @param hex - A hex string with a `0x` prefix.
Expand Down
32 changes: 21 additions & 11 deletions src/pg/pg-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,23 +502,33 @@ export class PgStore extends BasePgStore {

/**
* Returns an address balance for a BRC-20 token.
* @param ticker - BRC-20 ticker
* @param address - Owner address
* @param ticker - BRC-20 tickers
* @returns `DbBrc20Balance`
*/
async getBrc20Balance(args: {
ticker: string;
address: string;
}): Promise<DbBrc20Balance | undefined> {
const results = await this.sql<DbBrc20Balance[]>`
SELECT d.ticker, d.decimals, b.address, b.block_height, b.avail_balance, b.trans_balance
async getBrc20Balances(
args: {
address: string;
ticker?: string[];
} & DbInscriptionIndexPaging
): Promise<DbPaginatedResult<DbBrc20Balance>> {
const lowerTickers = args.ticker ? args.ticker.map(t => t.toLowerCase()) : undefined;
const results = await this.sql<(DbBrc20Balance & { total: number })[]>`
SELECT
d.ticker, d.decimals, b.address, b.block_height, b.avail_balance, b.trans_balance,
COUNT(*) OVER() as total
FROM brc20_balances AS b
INNER JOIN brc20_deploys AS d ON d.id = b.brc20_deploy_id
WHERE LOWER(d.ticker) = LOWER(${args.ticker}) AND b.address = ${args.address}
WHERE
b.address = ${args.address}
${lowerTickers ? this.sql`AND LOWER(d.ticker) IN ${this.sql(lowerTickers)}` : this.sql``}
LIMIT ${args.limit}
OFFSET ${args.offset}
`;
if (results.count === 1) {
return results[0];
}
return {
total: results[0]?.total ?? 0,
results: results ?? [],
};
}

async getBrc20History(args: { ticker: string } & DbInscriptionIndexPaging): Promise<void> {
Expand Down
61 changes: 36 additions & 25 deletions tests/brc20.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { buildApiServer } from '../src/api/init';
import { cycleMigrations } from '../src/pg/migrations';
import { PgStore } from '../src/pg/pg-store';
import { TestChainhookPayloadBuilder, brc20Reveal } from './helpers';
import { TestChainhookPayloadBuilder, TestFastifyServer, brc20Reveal } from './helpers';

describe('BRC-20', () => {
let db: PgStore;
let fastify: TestFastifyServer;

beforeEach(async () => {
db = await PgStore.connect({ skipMigrations: true });
fastify = await buildApiServer({ db });
await cycleMigrations();
});

afterEach(async () => {
await fastify.close();
await db.close();
});

Expand Down Expand Up @@ -204,6 +208,7 @@ describe('BRC-20', () => {

describe('mint', () => {
test('valid mints are saved and balance reflected', async () => {
const address = 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td';
await db.updateInscriptions(
new TestChainhookPayloadBuilder()
.apply()
Expand All @@ -224,7 +229,7 @@ describe('BRC-20', () => {
},
number: 5,
tx_id: '38c46a8bf7ec90bc7f6b797e7dc84baa97f4e5fd4286b92fe1b50176d03b18dc',
address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
address: address,
})
)
.build()
Expand All @@ -249,24 +254,27 @@ describe('BRC-20', () => {
},
number: 6,
tx_id: '8aec77f855549d98cb9fb5f35e02a03f9a2354fd05a5f89fc610b32c3b01f99f',
address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
address: address,
})
)
.build()
);

const balance = await db.getBrc20Balance({
ticker: 'pepe',
address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
});
expect(balance).toStrictEqual({
address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
avail_balance: '250000',
block_height: '775618',
decimals: 18,
ticker: 'PEPE',
trans_balance: '0',
const response1 = await fastify.inject({
method: 'GET',
url: `/ordinals/brc-20/balances?address=${address}`,
});
expect(response1.statusCode).toBe(200);
const responseJson1 = response1.json();
expect(responseJson1.total).toBe(1);
expect(responseJson1.results).toStrictEqual([
{
ticker: 'PEPE',
available_balance: '250000',
overall_balance: '250000',
transferrable_balance: '0',
},
]);

// New mint
await db.updateInscriptions(
Expand Down Expand Up @@ -295,18 +303,21 @@ describe('BRC-20', () => {
.build()
);

const balance2 = await db.getBrc20Balance({
ticker: 'pepe',
address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
});
expect(balance2).toStrictEqual({
address: 'bc1p3cyx5e2hgh53w7kpxcvm8s4kkega9gv5wfw7c4qxsvxl0u8x834qf0u2td',
avail_balance: '350000',
block_height: '775619',
decimals: 18,
ticker: 'PEPE',
trans_balance: '0',
const response2 = await fastify.inject({
method: 'GET',
url: `/ordinals/brc-20/balances?address=${address}`,
});
expect(response2.statusCode).toBe(200);
const responseJson2 = response2.json();
expect(responseJson2.total).toBe(1);
expect(responseJson2.results).toStrictEqual([
{
ticker: 'PEPE',
available_balance: '350000',
overall_balance: '350000',
transferrable_balance: '0',
},
]);
});

test('rollback mints deduct balance correctly', async () => {});
Expand Down

0 comments on commit f9c6654

Please sign in to comment.