From 6c5603ba899943accf9d23a1e396cd0a378dd9d2 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 28 Jun 2024 11:32:35 -0600 Subject: [PATCH 01/14] feat: address routes, rune route expansion --- api/src/api/init.ts | 2 + api/src/api/routes/address.ts | 55 ++++++++++++++++++++++ api/src/api/routes/etchings.ts | 85 +++++++++++++++++++++++++++++++--- api/src/api/schemas.ts | 31 +++++++++---- api/src/api/util/helpers.ts | 4 +- api/src/pg/pg-store.ts | 56 +++++++++++++++++++++- 6 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 api/src/api/routes/address.ts diff --git a/api/src/api/init.ts b/api/src/api/init.ts index 9d7c832..1f7861c 100644 --- a/api/src/api/init.ts +++ b/api/src/api/init.ts @@ -6,6 +6,7 @@ import FastifyMetrics, { IFastifyMetrics } from 'fastify-metrics'; import { Server } from 'http'; import { PgStore } from '../pg/pg-store'; import { EtchingRoutes } from './routes/etchings'; +import { AddressRoutes } from './routes/address'; export const Api: FastifyPluginAsync< Record, @@ -13,6 +14,7 @@ export const Api: FastifyPluginAsync< TypeBoxTypeProvider > = async fastify => { await fastify.register(EtchingRoutes); + await fastify.register(AddressRoutes); }; export async function buildApiServer(args: { db: PgStore }) { diff --git a/api/src/api/routes/address.ts b/api/src/api/routes/address.ts new file mode 100644 index 0000000..4da12a3 --- /dev/null +++ b/api/src/api/routes/address.ts @@ -0,0 +1,55 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { + AddressParamSchema, + LimitParamSchema, + OffsetParamSchema, + PaginatedResponse, + BalanceResponseSchema, +} from '../schemas'; +import { parseBalanceResponse } from '../util/helpers'; + +export const AddressRoutes: FastifyPluginCallback< + Record, + Server, + TypeBoxTypeProvider +> = (fastify, options, done) => { + // fastify.addHook('preHandler', handleInscriptionTransfersCache); + + fastify.get( + '/address/:address/balances', + { + schema: { + operationId: 'getAddressBalances', + summary: 'Get address balances', + description: 'Retrieves a paginated list of address balances', + tags: ['Runes'], + params: Type.Object({ + address: AddressParamSchema, + }), + querystring: Type.Object({ + offset: Type.Optional(OffsetParamSchema), + limit: Type.Optional(LimitParamSchema), + }), + response: { + 200: PaginatedResponse(BalanceResponseSchema, 'Paginated balances response'), + }, + }, + }, + async (request, reply) => { + const offset = request.query.offset ?? 0; + const limit = request.query.limit ?? 20; + const results = await fastify.db.getAddressBalances(request.params.address, offset, limit); + await reply.send({ + limit, + offset, + total: results.total, + results: results.results.map(r => parseBalanceResponse(r)), + }); + } + ); + + done(); +}; diff --git a/api/src/api/routes/etchings.ts b/api/src/api/routes/etchings.ts index e172286..57fc37a 100644 --- a/api/src/api/routes/etchings.ts +++ b/api/src/api/routes/etchings.ts @@ -4,14 +4,15 @@ import { Value } from '@sinclair/typebox/value'; import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; import { - BalanceResponseSchema, - EtchingActivityResponseSchema, + AddressParamSchema, EtchingParamSchema, EtchingResponseSchema, LimitParamSchema, NotFoundResponse, OffsetParamSchema, PaginatedResponse, + SimpleBalanceResponseSchema, + SimpleActivityResponseSchema, } from '../schemas'; import { parseBalanceResponse, @@ -87,8 +88,8 @@ export const EtchingRoutes: FastifyPluginCallback< '/etchings/:etching/activity', { schema: { - operationId: 'getEtchingActivity', - summary: 'Rune etching activity', + operationId: 'getRuneActivity', + summary: 'Rune rune activity', description: 'Retrieves all activity for a Rune', tags: ['Runes'], params: Type.Object({ @@ -99,14 +100,53 @@ export const EtchingRoutes: FastifyPluginCallback< limit: Type.Optional(LimitParamSchema), }), response: { - 200: PaginatedResponse(EtchingActivityResponseSchema, 'Paginated etchings response'), + 200: PaginatedResponse(SimpleActivityResponseSchema, 'Paginated activity response'), }, }, }, async (request, reply) => { const offset = request.query.offset ?? 0; const limit = request.query.limit ?? 20; - const results = await fastify.db.getEtchingActivity(request.params.etching, offset, limit); + const results = await fastify.db.getRuneActivity(request.params.etching, offset, limit); + await reply.send({ + limit, + offset, + total: results.total, + results: results.results.map(r => parseEtchingActivityResponse(r)), + }); + } + ); + + fastify.get( + '/etchings/:etching/activity/:address', + { + schema: { + operationId: 'getRuneAddressActivity', + summary: 'Rune rune activity for address', + description: 'Retrieves all activity for a Rune address', + tags: ['Runes'], + params: Type.Object({ + etching: EtchingParamSchema, + address: AddressParamSchema, + }), + querystring: Type.Object({ + offset: Type.Optional(OffsetParamSchema), + limit: Type.Optional(LimitParamSchema), + }), + response: { + 200: PaginatedResponse(SimpleActivityResponseSchema, 'Paginated activity response'), + }, + }, + }, + async (request, reply) => { + const offset = request.query.offset ?? 0; + const limit = request.query.limit ?? 20; + const results = await fastify.db.getRuneAddressActivity( + request.params.etching, + request.params.address, + offset, + limit + ); await reply.send({ limit, offset, @@ -132,7 +172,7 @@ export const EtchingRoutes: FastifyPluginCallback< limit: Type.Optional(LimitParamSchema), }), response: { - 200: PaginatedResponse(BalanceResponseSchema, 'Paginated holders response'), + 200: PaginatedResponse(SimpleBalanceResponseSchema, 'Paginated holders response'), }, }, }, @@ -149,5 +189,36 @@ export const EtchingRoutes: FastifyPluginCallback< } ); + fastify.get( + '/etchings/:etching/holders/:address', + { + schema: { + operationId: 'getRuneHolderBalance', + summary: 'Rune holder balance', + description: 'Retrieves holder balance for a specific Rune', + tags: ['Runes'], + params: Type.Object({ + etching: EtchingParamSchema, + address: AddressParamSchema, + }), + response: { + 404: NotFoundResponse, + 200: SimpleBalanceResponseSchema, + }, + }, + }, + async (request, reply) => { + const balance = await fastify.db.getRuneAddressBalance( + request.params.etching, + request.params.address + ); + if (!balance) { + await reply.code(404).send(Value.Create(NotFoundResponse)); + } else { + await reply.send(parseBalanceResponse(balance)); + } + } + ); + done(); }; diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index 8da6df0..5f5cf85 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -59,6 +59,9 @@ export const RuneSpacedNameSchemaCType = TypeCompiler.Compile(RuneSpacedNameSche export const EtchingParamSchema = Type.Union([RuneIdSchema, RuneNameSchema, RuneSpacedNameSchema]); export type EtchingParam = Static; +export const AddressParamSchema = Type.String(); +export type AddressParam = Static; + // ========================== // Responses // ========================== @@ -105,13 +108,14 @@ export const EtchingResponseSchema = Type.Object({ export type EtchingResponse = Static; const RuneDetailResponseSchema = Type.Object({ - id: Type.String({ examples: ['840000:1'] }), - name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), - spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), + rune: Type.Object({ + id: Type.String({ examples: ['840000:1'] }), + name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), + spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), + }), }); -export const EtchingActivityResponseSchema = Type.Object({ - rune: RuneDetailResponseSchema, +export const SimpleActivityResponseSchema = Type.Object({ block_height: Type.Integer({ examples: [840000] }), tx_index: Type.Integer({ examples: [1] }), tx_id: Type.String({ @@ -134,13 +138,24 @@ export const EtchingActivityResponseSchema = Type.Object({ ]), timestamp: Type.Integer({ examples: [1713571767] }), }); -export type EtchingActivityResponse = Static; +export type SimpleActivityResponse = Static; + +export const ActivityResponseSchema = Type.Intersect([ + RuneDetailResponseSchema, + SimpleActivityResponseSchema, +]); +export type ActivityResponse = Static; -export const BalanceResponseSchema = Type.Object({ - rune: RuneDetailResponseSchema, +export const SimpleBalanceResponseSchema = Type.Object({ address: Type.Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), balance: Type.String({ examples: ['11000000000'] }), }); +export type SimpleBalanceResponse = Static; + +export const BalanceResponseSchema = Type.Intersect([ + RuneDetailResponseSchema, + SimpleBalanceResponseSchema, +]); export type BalanceResponse = Static; export const NotFoundResponse = Type.Object( diff --git a/api/src/api/util/helpers.ts b/api/src/api/util/helpers.ts index bb3aa01..e46d4b3 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -1,6 +1,6 @@ import BigNumber from 'bignumber.js'; import { DbBalance, DbItemWithRune, DbLedgerEntry, DbRune } from '../../pg/types'; -import { EtchingResponse, EtchingActivityResponse, BalanceResponse } from '../schemas'; +import { EtchingResponse, ActivityResponse, BalanceResponse } from '../schemas'; function divisibility(num: string, decimals: number): string { return new BigNumber(num).shiftedBy(-1 * decimals).toFixed(decimals); @@ -37,7 +37,7 @@ export function parseEtchingResponse(rune: DbRune): EtchingResponse { export function parseEtchingActivityResponse( entry: DbItemWithRune -): EtchingActivityResponse { +): ActivityResponse { return { rune: { id: entry.rune_id, diff --git a/api/src/pg/pg-store.ts b/api/src/pg/pg-store.ts index 3ad14e3..9e504c0 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -15,6 +15,7 @@ import { DbRune, } from './types'; import { + AddressParam, EtchingParam, LimitParam, OffsetParam, @@ -81,7 +82,7 @@ export class PgStore extends BasePgStore { }; } - async getEtchingActivity( + async getRuneActivity( id: EtchingParam, offset: OffsetParam, limit: LimitParam @@ -100,6 +101,27 @@ export class PgStore extends BasePgStore { }; } + async getRuneAddressActivity( + id: EtchingParam, + address: AddressParam, + offset: OffsetParam, + limit: LimitParam + ): Promise>> { + const results = await this.sql>[]>` + SELECT l.*, r.name, r.spaced_name, r.divisibility, COUNT(*) OVER() AS total + FROM ledger AS l + INNER JOIN runes AS r ON r.id = l.rune_id + WHERE ${getEtchingIdWhereCondition(this.sql, id, 'r')} + AND address = ${address} + ORDER BY l.block_height DESC, l.tx_index DESC + OFFSET ${offset} LIMIT ${limit} + `; + return { + total: results[0]?.total ?? 0, + results, + }; + } + async getRuneHolders( id: EtchingParam, offset: OffsetParam, @@ -118,4 +140,36 @@ export class PgStore extends BasePgStore { results, }; } + + async getRuneAddressBalance( + id: EtchingParam, + address: AddressParam + ): Promise | undefined> { + const results = await this.sql[]>` + SELECT b.*, r.name, r.spaced_name, r.divisibility + FROM balances AS b + INNER JOIN runes AS r ON r.id = b.rune_id + WHERE ${getEtchingIdWhereCondition(this.sql, id, 'r')} AND address = ${address} + `; + return results[0]; + } + + async getAddressBalances( + address: AddressParam, + offset: OffsetParam, + limit: LimitParam + ): Promise>> { + const results = await this.sql>[]>` + SELECT b.*, r.name, r.spaced_name, r.divisibility, COUNT(*) OVER() AS total + FROM balances AS b + INNER JOIN runes AS r ON r.id = b.rune_id + WHERE address = ${address} + ORDER BY balance DESC + OFFSET ${offset} LIMIT ${limit} + `; + return { + total: results[0]?.total ?? 0, + results, + }; + } } From 15844994845076df957ed6846eccb4484045bfb4 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 28 Jun 2024 13:46:54 -0600 Subject: [PATCH 02/14] feat: add cache to api endpoints --- api/package-lock.json | 12 +-- api/package.json | 2 +- api/src/api/init.ts | 33 +------ api/src/api/routes/address.ts | 9 +- api/src/api/routes/etchings.ts | 21 ++-- api/src/api/schemas.ts | 25 ++--- api/src/api/util/cache.ts | 153 +++--------------------------- api/src/api/util/helpers.ts | 2 + api/src/index.ts | 2 +- api/src/pg/pg-store.ts | 7 ++ api/src/pg/types.ts | 2 + migrations/V1__runes.sql | 10 +- migrations/V2__ledger.sql | 1 + src/db/cache/index_cache.rs | 13 ++- src/db/cache/transaction_cache.rs | 18 ++++ src/db/index.rs | 3 +- src/db/mod.rs | 13 ++- src/db/models/db_ledger_entry.rs | 4 + src/db/models/db_rune.rs | 7 ++ src/db/types/pg_numeric_u128.rs | 2 +- 20 files changed, 118 insertions(+), 221 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index 080d6f3..d307902 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -14,7 +14,7 @@ "@fastify/multipart": "^7.1.0", "@fastify/swagger": "^8.3.1", "@fastify/type-provider-typebox": "3.2.0", - "@hirosystems/api-toolkit": "^1.5.0", + "@hirosystems/api-toolkit": "^1.6.0", "@types/node": "^18.13.0", "bignumber.js": "^9.1.2", "env-schema": "^5.2.1", @@ -1346,13 +1346,14 @@ } }, "node_modules/@hirosystems/api-toolkit": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@hirosystems/api-toolkit/-/api-toolkit-1.5.0.tgz", - "integrity": "sha512-f7rL2Bct+tW5gtYEZwCFQYQnkEIgGH+yoBYe807c+/gYItfWa9bPdY8KAFo+5AD1TbvP1bECrUClhK2TCCc1tA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@hirosystems/api-toolkit/-/api-toolkit-1.6.0.tgz", + "integrity": "sha512-59IMo4lsq3ASvXKeL5gVXyaMlvblOANDQaxi4lDAja/gbVsNp4sPyrVvGEhNU17zJEiaKVf54AKCyd/ywwjFng==", "dependencies": { "@fastify/cors": "^8.0.0", "@fastify/swagger": "^8.3.1", "@fastify/type-provider-typebox": "^3.2.0", + "@sinclair/typebox": "^0.28.20", "fastify": "^4.3.0", "fastify-metrics": "^10.2.0", "node-pg-migrate": "^6.2.2", @@ -3371,8 +3372,7 @@ "node_modules/@sinclair/typebox": { "version": "0.28.20", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.28.20.tgz", - "integrity": "sha512-QCF3BGfacwD+3CKhGsMeixnwOmX4AWgm61nKkNdRStyLVu0mpVFYlDSY8gVBOOED1oSwzbJauIWl/+REj8K5+w==", - "peer": true + "integrity": "sha512-QCF3BGfacwD+3CKhGsMeixnwOmX4AWgm61nKkNdRStyLVu0mpVFYlDSY8gVBOOED1oSwzbJauIWl/+REj8K5+w==" }, "node_modules/@sindresorhus/is": { "version": "4.6.0", diff --git a/api/package.json b/api/package.json index 07828b2..43ab750 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@fastify/multipart": "^7.1.0", "@fastify/swagger": "^8.3.1", "@fastify/type-provider-typebox": "3.2.0", - "@hirosystems/api-toolkit": "^1.5.0", + "@hirosystems/api-toolkit": "^1.6.0", "@types/node": "^18.13.0", "bignumber.js": "^9.1.2", "env-schema": "^5.2.1", diff --git a/api/src/api/init.ts b/api/src/api/init.ts index 1f7861c..3c9d182 100644 --- a/api/src/api/init.ts +++ b/api/src/api/init.ts @@ -1,8 +1,6 @@ -import FastifyCors from '@fastify/cors'; import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; -import { PINO_LOGGER_CONFIG, isProdEnv } from '@hirosystems/api-toolkit'; -import Fastify, { FastifyPluginAsync } from 'fastify'; -import FastifyMetrics, { IFastifyMetrics } from 'fastify-metrics'; +import { buildFastifyApiServer } from '@hirosystems/api-toolkit'; +import { FastifyPluginAsync } from 'fastify'; import { Server } from 'http'; import { PgStore } from '../pg/pg-store'; import { EtchingRoutes } from './routes/etchings'; @@ -18,36 +16,11 @@ export const Api: FastifyPluginAsync< }; export async function buildApiServer(args: { db: PgStore }) { - const fastify = Fastify({ - trustProxy: true, - logger: PINO_LOGGER_CONFIG, - }).withTypeProvider(); + const fastify = await buildFastifyApiServer(); fastify.decorate('db', args.db); - if (isProdEnv) { - await fastify.register(FastifyMetrics, { endpoint: null }); - } - await fastify.register(FastifyCors); await fastify.register(Api, { prefix: '/runes/v1' }); await fastify.register(Api, { prefix: '/runes' }); return fastify; } - -export async function buildPromServer(args: { metrics: IFastifyMetrics }) { - const promServer = Fastify({ - trustProxy: true, - logger: PINO_LOGGER_CONFIG, - }); - - promServer.route({ - url: '/metrics', - method: 'GET', - logLevel: 'info', - handler: async (_, reply) => { - await reply.type('text/plain').send(await args.metrics.client.register.metrics()); - }, - }); - - return promServer; -} diff --git a/api/src/api/routes/address.ts b/api/src/api/routes/address.ts index 4da12a3..6683ca6 100644 --- a/api/src/api/routes/address.ts +++ b/api/src/api/routes/address.ts @@ -6,17 +6,18 @@ import { AddressParamSchema, LimitParamSchema, OffsetParamSchema, - PaginatedResponse, BalanceResponseSchema, } from '../schemas'; import { parseBalanceResponse } from '../util/helpers'; +import { Optional, PaginatedResponse } from '@hirosystems/api-toolkit'; +import { handleCache } from '../util/cache'; export const AddressRoutes: FastifyPluginCallback< Record, Server, TypeBoxTypeProvider > = (fastify, options, done) => { - // fastify.addHook('preHandler', handleInscriptionTransfersCache); + fastify.addHook('preHandler', handleCache); fastify.get( '/address/:address/balances', @@ -30,8 +31,8 @@ export const AddressRoutes: FastifyPluginCallback< address: AddressParamSchema, }), querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetParamSchema), + limit: Optional(LimitParamSchema), }), response: { 200: PaginatedResponse(BalanceResponseSchema, 'Paginated balances response'), diff --git a/api/src/api/routes/etchings.ts b/api/src/api/routes/etchings.ts index 57fc37a..36bfad8 100644 --- a/api/src/api/routes/etchings.ts +++ b/api/src/api/routes/etchings.ts @@ -10,7 +10,6 @@ import { LimitParamSchema, NotFoundResponse, OffsetParamSchema, - PaginatedResponse, SimpleBalanceResponseSchema, SimpleActivityResponseSchema, } from '../schemas'; @@ -19,13 +18,15 @@ import { parseEtchingActivityResponse, parseEtchingResponse, } from '../util/helpers'; +import { Optional, PaginatedResponse } from '@hirosystems/api-toolkit'; +import { handleCache } from '../util/cache'; export const EtchingRoutes: FastifyPluginCallback< Record, Server, TypeBoxTypeProvider > = (fastify, options, done) => { - // fastify.addHook('preHandler', handleInscriptionTransfersCache); + fastify.addHook('preHandler', handleCache); fastify.get( '/etchings', @@ -36,8 +37,8 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves a paginated list of rune etchings', tags: ['Runes'], querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetParamSchema), + limit: Optional(LimitParamSchema), }), response: { 200: PaginatedResponse(EtchingResponseSchema, 'Paginated etchings response'), @@ -96,8 +97,8 @@ export const EtchingRoutes: FastifyPluginCallback< etching: EtchingParamSchema, }), querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetParamSchema), + limit: Optional(LimitParamSchema), }), response: { 200: PaginatedResponse(SimpleActivityResponseSchema, 'Paginated activity response'), @@ -130,8 +131,8 @@ export const EtchingRoutes: FastifyPluginCallback< address: AddressParamSchema, }), querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetParamSchema), + limit: Optional(LimitParamSchema), }), response: { 200: PaginatedResponse(SimpleActivityResponseSchema, 'Paginated activity response'), @@ -168,8 +169,8 @@ export const EtchingRoutes: FastifyPluginCallback< etching: EtchingParamSchema, }), querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetParamSchema), + limit: Optional(LimitParamSchema), }), response: { 200: PaginatedResponse(SimpleBalanceResponseSchema, 'Paginated holders response'), diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index 5f5cf85..0e984b0 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -1,5 +1,5 @@ import { SwaggerOptions } from '@fastify/swagger'; -import { SERVER_VERSION } from '@hirosystems/api-toolkit'; +import { Nullable, Optional, SERVER_VERSION } from '@hirosystems/api-toolkit'; import { Static, TSchema, Type } from '@sinclair/typebox'; import { TypeCompiler } from '@sinclair/typebox/compiler'; @@ -29,8 +29,6 @@ export const OpenApiSchemaOptions: SwaggerOptions = { }, }; -const Nullable = (type: T) => Type.Union([type, Type.Null()]); - // ========================== // Parameters // ========================== @@ -66,22 +64,14 @@ export type AddressParam = Static; // Responses // ========================== -export const PaginatedResponse = (type: T, title: string) => - Type.Object( - { - limit: Type.Integer({ examples: [20] }), - offset: Type.Integer({ examples: [0] }), - total: Type.Integer({ examples: [1] }), - results: Type.Array(type), - }, - { title } - ); - export const EtchingResponseSchema = Type.Object({ id: Type.String({ examples: ['840000:1'] }), name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), number: Type.Integer({ examples: [1] }), + block_hash: Type.String({ + examples: ['00000000000000000000c9787573a1f1775a2b56b403a2d0c7957e9a5bc754bb'], + }), block_height: Type.Integer({ examples: [840000] }), tx_index: Type.Integer({ examples: [1] }), tx_id: Type.String({ @@ -116,6 +106,9 @@ const RuneDetailResponseSchema = Type.Object({ }); export const SimpleActivityResponseSchema = Type.Object({ + block_hash: Type.String({ + examples: ['00000000000000000000c9787573a1f1775a2b56b403a2d0c7957e9a5bc754bb'], + }), block_height: Type.Integer({ examples: [840000] }), tx_index: Type.Integer({ examples: [1] }), tx_id: Type.String({ @@ -125,7 +118,7 @@ export const SimpleActivityResponseSchema = Type.Object({ output: Type.String({ examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e:100'], }), - address: Type.Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), + address: Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), receiver_address: Type.Optional( Type.String({ examples: ['bc1pgdrveee2v4ez95szaakw5gkd8eennv2dddf9rjdrlt6ch56lzrrsxgvazt'] }) ), @@ -147,7 +140,7 @@ export const ActivityResponseSchema = Type.Intersect([ export type ActivityResponse = Static; export const SimpleBalanceResponseSchema = Type.Object({ - address: Type.Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), + address: Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), balance: Type.String({ examples: ['11000000000'] }), }); export type SimpleBalanceResponse = Static; diff --git a/api/src/api/util/cache.ts b/api/src/api/util/cache.ts index 224244d..d6fbfdc 100644 --- a/api/src/api/util/cache.ts +++ b/api/src/api/util/cache.ts @@ -1,139 +1,14 @@ -// import { FastifyReply, FastifyRequest } from 'fastify'; -// import { InscriptionIdParamCType, InscriptionNumberParamCType } from '../schemas'; -// import { logger } from '@hirosystems/api-toolkit'; - -// enum ETagType { -// inscriptionsIndex, -// inscription, -// inscriptionsPerBlock, -// } - -// /** -// * A `Cache-Control` header used for re-validation based caching. -// * * `public` == allow proxies/CDNs to cache as opposed to only local browsers. -// * * `no-cache` == clients can cache a resource but should revalidate each time before using it. -// * * `must-revalidate` == somewhat redundant directive to assert that cache must be revalidated, required by some CDNs -// */ -// const CACHE_CONTROL_MUST_REVALIDATE = 'public, no-cache, must-revalidate'; - -// export async function handleInscriptionCache(request: FastifyRequest, reply: FastifyReply) { -// return handleCache(ETagType.inscription, request, reply); -// } - -// export async function handleInscriptionTransfersCache( -// request: FastifyRequest, -// reply: FastifyReply -// ) { -// return handleCache(ETagType.inscriptionsIndex, request, reply); -// } - -// export async function handleInscriptionsPerBlockCache( -// request: FastifyRequest, -// reply: FastifyReply -// ) { -// return handleCache(ETagType.inscriptionsPerBlock, request, reply); -// } - -// async function handleCache(type: ETagType, request: FastifyRequest, reply: FastifyReply) { -// const ifNoneMatch = parseIfNoneMatchHeader(request.headers['if-none-match']); -// let etag: string | undefined; -// switch (type) { -// case ETagType.inscription: -// etag = await getInscriptionLocationEtag(request); -// break; -// case ETagType.inscriptionsIndex: -// etag = await getInscriptionsIndexEtag(request); -// break; -// case ETagType.inscriptionsPerBlock: -// etag = await request.server.db.getInscriptionsPerBlockETag(); -// break; -// } -// if (etag) { -// if (ifNoneMatch && ifNoneMatch.includes(etag)) { -// await reply.header('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).code(304).send(); -// } else { -// void reply.headers({ 'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE, ETag: `"${etag}"` }); -// } -// } -// } - -// /** -// * Retrieve the inscriptions's location timestamp as a UNIX epoch so we can use it as the response -// * ETag. -// * @param request - Fastify request -// * @returns Etag string -// */ -// async function getInscriptionLocationEtag(request: FastifyRequest): Promise { -// try { -// const components = request.url.split('/'); -// do { -// const lastElement = components.pop(); -// if (lastElement && lastElement.length) { -// if (InscriptionIdParamCType.Check(lastElement)) { -// return await request.server.db.getInscriptionETag({ genesis_id: lastElement }); -// } else if (InscriptionNumberParamCType.Check(parseInt(lastElement))) { -// return await request.server.db.getInscriptionETag({ number: lastElement }); -// } -// } -// } while (components.length); -// } catch (error) { -// return; -// } -// } - -// /** -// * Get an ETag based on the last state of all inscriptions. -// * @param request - Fastify request -// * @returns ETag string -// */ -// async function getInscriptionsIndexEtag(request: FastifyRequest): Promise { -// try { -// return await request.server.db.getInscriptionsIndexETag(); -// } catch (error) { -// return; -// } -// } - -// /** -// * Parses the etag values from a raw `If-None-Match` request header value. -// * The wrapping double quotes (if any) and validation prefix (if any) are stripped. -// * The parsing is permissive to account for commonly non-spec-compliant clients, proxies, CDNs, etc. -// * E.g. the value: -// * ```js -// * `"a", W/"b", c,d, "e", "f"` -// * ``` -// * Would be parsed and returned as: -// * ```js -// * ['a', 'b', 'c', 'd', 'e', 'f'] -// * ``` -// * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#syntax -// * ``` -// * If-None-Match: "etag_value" -// * If-None-Match: "etag_value", "etag_value", ... -// * If-None-Match: * -// * ``` -// * @param ifNoneMatchHeaderValue - raw header value -// * @returns an array of etag values -// */ -// function parseIfNoneMatchHeader(ifNoneMatchHeaderValue: string | undefined): string[] | undefined { -// if (!ifNoneMatchHeaderValue) { -// return undefined; -// } -// // Strip wrapping double quotes like `"hello"` and the ETag validation-prefix like `W/"hello"`. -// // The API returns compliant, strong-validation ETags (double quoted ASCII), but can't control what -// // clients, proxies, CDNs, etc may provide. -// const normalized = /^(?:"|W\/")?(.*?)"?$/gi.exec(ifNoneMatchHeaderValue.trim())?.[1]; -// if (!normalized) { -// // This should never happen unless handling a buggy request with something like `If-None-Match: ""`, -// // or if there's a flaw in the above code. Log warning for now. -// logger.warn(`Normalized If-None-Match header is falsy: ${ifNoneMatchHeaderValue}`); -// return undefined; -// } else if (normalized.includes(',')) { -// // Multiple etag values provided, likely irrelevant extra values added by a proxy/CDN. -// // Split on comma, also stripping quotes, weak-validation prefixes, and extra whitespace. -// return normalized.split(/(?:W\/"|")?(?:\s*),(?:\s*)(?:W\/"|")?/gi); -// } else { -// // Single value provided (the typical case) -// return [normalized]; -// } -// } +import { CACHE_CONTROL_MUST_REVALIDATE, parseIfNoneMatchHeader } from '@hirosystems/api-toolkit'; +import { FastifyReply, FastifyRequest } from 'fastify'; + +export async function handleCache(request: FastifyRequest, reply: FastifyReply) { + const ifNoneMatch = parseIfNoneMatchHeader(request.headers['if-none-match']); + const etag = await request.server.db.getChainTipEtag(); + if (etag) { + if (ifNoneMatch && ifNoneMatch.includes(etag)) { + await reply.header('Cache-Control', CACHE_CONTROL_MUST_REVALIDATE).code(304).send(); + } else { + void reply.headers({ 'Cache-Control': CACHE_CONTROL_MUST_REVALIDATE, ETag: `"${etag}"` }); + } + } +} diff --git a/api/src/api/util/helpers.ts b/api/src/api/util/helpers.ts index e46d4b3..257520d 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -12,6 +12,7 @@ export function parseEtchingResponse(rune: DbRune): EtchingResponse { number: rune.number, name: rune.name, spaced_name: rune.spaced_name, + block_hash: rune.block_hash, block_height: rune.block_height, tx_index: rune.tx_index, tx_id: rune.tx_id, @@ -44,6 +45,7 @@ export function parseEtchingActivityResponse( name: entry.name, spaced_name: entry.spaced_name, }, + block_hash: entry.block_hash, block_height: entry.block_height, tx_index: entry.tx_index, tx_id: entry.tx_id, diff --git a/api/src/index.ts b/api/src/index.ts index 3b22938..33592ba 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,5 +1,5 @@ import { isProdEnv, logger, registerShutdownConfig } from '@hirosystems/api-toolkit'; -import { buildApiServer, buildPromServer } from './api/init'; +import { buildApiServer } from './api/init'; import { ENV } from './env'; import { PgStore } from './pg/pg-store'; // import { ApiMetrics } from './metrics/metrics'; diff --git a/api/src/pg/pg-store.ts b/api/src/pg/pg-store.ts index 9e504c0..8bd1255 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -60,6 +60,13 @@ export class PgStore extends BasePgStore { super(sql); } + async getChainTipEtag(): Promise { + const result = await this.sql<{ etag: string }[]>` + SELECT block_hash AS etag FROM ledger ORDER BY block_height DESC LIMIT 1 + `; + return result[0]?.etag; + } + async getEtching(id: EtchingParam): Promise { const result = await this.sql` SELECT * FROM runes WHERE ${getEtchingIdWhereCondition(this.sql, id)} diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index 7718985..299667c 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -10,6 +10,7 @@ export type DbRune = { number: number; name: string; spaced_name: string; + block_hash: string; block_height: number; tx_index: number; tx_id: string; @@ -35,6 +36,7 @@ type DbLedgerOperation = 'mint' | 'burn' | 'send' | 'receive'; export type DbLedgerEntry = { rune_id: string; + block_hash: string; block_height: number; tx_index: number; tx_id: string; diff --git a/migrations/V1__runes.sql b/migrations/V1__runes.sql index 6a1164a..1a7f88f 100644 --- a/migrations/V1__runes.sql +++ b/migrations/V1__runes.sql @@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS runes ( number BIGINT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE, spaced_name TEXT NOT NULL, + block_hash TEXT NOT NULL, block_height NUMERIC NOT NULL, tx_index BIGINT NOT NULL, tx_id TEXT NOT NULL, @@ -27,10 +28,11 @@ CREATE INDEX runes_block_height_tx_index_index ON runes (block_height, tx_index) -- Insert default 'UNCOMMON•GOODS' INSERT INTO runes ( - id, number, name, spaced_name, block_height, tx_index, tx_id, symbol, terms_amount, terms_cap, - terms_height_start, terms_height_end, timestamp + id, number, name, spaced_name, block_hash, block_height, tx_index, tx_id, symbol, terms_amount, + terms_cap, terms_height_start, terms_height_end, timestamp ) VALUES ( - '1:0', 0, 'UNCOMMONGOODS', 'UNCOMMON•GOODS', 840000, 0, '', '⧉', 1, '340282366920938463463374607431768211455', 840000, - 1050000, 0 + '1:0', 0, 'UNCOMMONGOODS', 'UNCOMMON•GOODS', + '0000000000000000000320283a032748cef8227873ff4872689bf23f1cda83a5', 840000, 0, '', '⧉', 1, + '340282366920938463463374607431768211455', 840000, 1050000, 0 ); diff --git a/migrations/V2__ledger.sql b/migrations/V2__ledger.sql index 003b349..8c94972 100644 --- a/migrations/V2__ledger.sql +++ b/migrations/V2__ledger.sql @@ -2,6 +2,7 @@ CREATE TYPE ledger_operation AS ENUM ('mint', 'burn', 'send', 'receive'); CREATE TABLE IF NOT EXISTS ledger ( rune_id TEXT NOT NULL, + block_hash TEXT NOT NULL, block_height NUMERIC NOT NULL, tx_index BIGINT NOT NULL, event_index BIGINT NOT NULL, diff --git a/src/db/cache/index_cache.rs b/src/db/cache/index_cache.rs index 2017d94..a683b39 100644 --- a/src/db/cache/index_cache.rs +++ b/src/db/cache/index_cache.rs @@ -50,7 +50,7 @@ impl IndexCache { next_rune_number: max_rune_number + 1, rune_cache: LruCache::new(cap), output_cache: LruCache::new(cap), - tx_cache: TransactionCache::new(network, 1, 0, &"".to_string(), 0), + tx_cache: TransactionCache::new(network, &"".to_string(), 1, 0, &"".to_string(), 0), db_cache: DbCache::new(), } } @@ -58,13 +58,20 @@ impl IndexCache { /// Creates a fresh transaction index cache. pub async fn begin_transaction( &mut self, + block_hash: &String, block_height: u64, tx_index: u32, tx_id: &String, timestamp: u32, ) { - self.tx_cache = - TransactionCache::new(self.network, block_height, tx_index, tx_id, timestamp); + self.tx_cache = TransactionCache::new( + self.network, + block_hash, + block_height, + tx_index, + tx_id, + timestamp, + ); } /// Finalizes the current transaction index cache. diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index 0ad3ad2..e5a7453 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -22,6 +22,7 @@ pub struct InputRuneBalance { /// Holds cached data relevant to a single transaction during indexing. pub struct TransactionCache { network: Network, + pub block_hash: String, pub block_height: u64, pub tx_index: u32, pub tx_id: String, @@ -44,6 +45,7 @@ pub struct TransactionCache { impl TransactionCache { pub fn new( network: Network, + block_hash: &String, block_height: u64, tx_index: u32, tx_id: &String, @@ -51,6 +53,7 @@ impl TransactionCache { ) -> Self { TransactionCache { network, + block_hash: block_hash.clone(), block_height, tx_index, next_event_index: 0, @@ -121,6 +124,7 @@ impl TransactionCache { results.push(new_ledger_entry( balance.amount, *rune_id, + &self.block_hash, self.block_height, self.tx_index, &self.tx_id, @@ -144,6 +148,7 @@ impl TransactionCache { for (rune_id, unallocated) in self.input_runes.iter_mut() { results.extend(move_rune_balance_to_output( self.network, + &self.block_hash, self.block_height, &self.tx_id, self.tx_index, @@ -169,6 +174,7 @@ impl TransactionCache { let db_rune = DbRune::from_etching( etching, number, + &self.block_hash, self.block_height, self.tx_index, &self.tx_id, @@ -197,6 +203,7 @@ impl TransactionCache { let db_rune = DbRune::from_cenotaph_etching( rune, number, + &self.block_hash, self.block_height, self.tx_index, &self.tx_id, @@ -219,6 +226,7 @@ impl TransactionCache { new_ledger_entry( mint_amount.0, rune_id.clone(), + &self.block_hash, self.block_height, self.tx_index, &self.tx_id, @@ -238,6 +246,7 @@ impl TransactionCache { new_ledger_entry( mint_amount.0, rune_id.clone(), + &self.block_hash, self.block_height, self.tx_index, &self.tx_id, @@ -288,6 +297,7 @@ impl TransactionCache { ); results.extend(move_rune_balance_to_output( self.network, + &self.block_hash, self.block_height, &self.tx_id, self.tx_index, @@ -322,6 +332,7 @@ impl TransactionCache { } results.extend(move_rune_balance_to_output( self.network, + &self.block_hash, self.block_height, &self.tx_id, self.tx_index, @@ -341,6 +352,7 @@ impl TransactionCache { let amount = edict.amount.min(unallocated); results.extend(move_rune_balance_to_output( self.network, + &self.block_hash, self.block_height, &self.tx_id, self.tx_index, @@ -364,6 +376,7 @@ impl TransactionCache { } results.extend(move_rune_balance_to_output( self.network, + &self.block_hash, self.block_height, &self.tx_id, self.tx_index, @@ -406,6 +419,7 @@ impl TransactionCache { fn new_ledger_entry( amount: u128, rune_id: RuneId, + block_hash: &String, block_height: u64, tx_index: u32, tx_id: &String, @@ -419,6 +433,7 @@ fn new_ledger_entry( let entry = DbLedgerEntry::from_values( amount, rune_id, + block_hash, block_height, tx_index, *next_event_index, @@ -438,6 +453,7 @@ fn new_ledger_entry( /// transferred. If `output` is `None`, the runes will be burnt. fn move_rune_balance_to_output( network: Network, + block_hash: &String, block_height: u64, tx_id: &String, tx_index: u32, @@ -497,6 +513,7 @@ fn move_rune_balance_to_output( results.push(new_ledger_entry( balance_taken, *rune_id, + block_hash, block_height, tx_index, tx_id, @@ -527,6 +544,7 @@ fn move_rune_balance_to_output( results.push(new_ledger_entry( total_sent, *rune_id, + block_hash, block_height, tx_index, &tx_id, diff --git a/src/db/index.rs b/src/db/index.rs index 6e17f3f..c1f5118 100644 --- a/src/db/index.rs +++ b/src/db/index.rs @@ -68,6 +68,7 @@ pub async fn index_block( ctx: &Context, ) { let stopwatch = std::time::Instant::now(); + let block_hash = &block.block_identifier.hash; let block_height = block.block_identifier.index; info!(ctx.expect_logger(), "Indexing block {}...", block_height); let mut db_tx = pg_client @@ -79,7 +80,7 @@ pub async fn index_block( let tx_index = tx.metadata.index; let tx_id = &tx.transaction_identifier.hash; index_cache - .begin_transaction(block_height, tx_index, tx_id, block.timestamp) + .begin_transaction(block_hash, block_height, tx_index, tx_id, block.timestamp) .await; if let Some(artifact) = Runestone::decipher(&transaction) { match artifact { diff --git a/src/db/mod.rs b/src/db/mod.rs index f2813a6..54f6fdb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -99,9 +99,9 @@ pub async fn pg_insert_rune_rows( ) -> Result { let stmt = db_tx.prepare( "INSERT INTO runes - (id, number, name, spaced_name, block_height, tx_index, tx_id, divisibility, premine, symbol, terms_amount, terms_cap, - terms_height_start, terms_height_end, terms_offset_start, terms_offset_end, turbo, timestamp) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + (id, number, name, spaced_name, block_hash, block_height, tx_index, tx_id, divisibility, premine, symbol, terms_amount, + terms_cap, terms_height_start, terms_height_end, terms_offset_start, terms_offset_end, turbo, timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (name) DO NOTHING" ).await.expect("Unable to prepare statement"); for row in rows.iter() { @@ -113,6 +113,7 @@ pub async fn pg_insert_rune_rows( &row.number, &row.name, &row.spaced_name, + &row.block_hash, &row.block_height, &row.tx_index, &row.tx_id, @@ -243,8 +244,9 @@ pub async fn pg_insert_ledger_entries( let stmt = db_tx .prepare( "INSERT INTO ledger - (rune_id, block_height, tx_index, event_index, tx_id, output, address, receiver_address, amount, operation, timestamp) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + (rune_id, block_hash, block_height, tx_index, event_index, tx_id, output, address, receiver_address, amount, operation, + timestamp) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", ) .await .expect("Unable to prepare statement"); @@ -254,6 +256,7 @@ pub async fn pg_insert_ledger_entries( &stmt, &[ &row.rune_id, + &row.block_hash, &row.block_height, &row.tx_index, &row.event_index, diff --git a/src/db/models/db_ledger_entry.rs b/src/db/models/db_ledger_entry.rs index 6986918..917b8d7 100644 --- a/src/db/models/db_ledger_entry.rs +++ b/src/db/models/db_ledger_entry.rs @@ -11,6 +11,7 @@ use super::db_ledger_operation::DbLedgerOperation; #[derive(Debug, Clone)] pub struct DbLedgerEntry { pub rune_id: String, + pub block_hash: String, pub block_height: PgNumericU64, pub tx_index: PgBigIntU32, pub event_index: PgBigIntU32, @@ -27,6 +28,7 @@ impl DbLedgerEntry { pub fn from_values( amount: u128, rune_id: RuneId, + block_hash: &String, block_height: u64, tx_index: u32, event_index: u32, @@ -39,6 +41,7 @@ impl DbLedgerEntry { ) -> Self { DbLedgerEntry { rune_id: rune_id.to_string(), + block_hash: block_hash[2..].to_string(), block_height: PgNumericU64(block_height), tx_index: PgBigIntU32(tx_index), event_index: PgBigIntU32(event_index), @@ -55,6 +58,7 @@ impl DbLedgerEntry { pub fn from_pg_row(row: &Row) -> Self { DbLedgerEntry { rune_id: row.get("rune_id"), + block_hash: row.get("block_hash"), block_height: row.get("block_height"), tx_index: row.get("tx_index"), event_index: row.get("event_index"), diff --git a/src/db/models/db_rune.rs b/src/db/models/db_rune.rs index 0c17044..4db44ef 100644 --- a/src/db/models/db_rune.rs +++ b/src/db/models/db_rune.rs @@ -13,6 +13,7 @@ pub struct DbRune { pub number: PgBigIntU32, pub name: String, pub spaced_name: String, + pub block_hash: String, pub block_height: PgNumericU64, pub tx_index: PgBigIntU32, pub tx_id: String, @@ -38,6 +39,7 @@ impl DbRune { pub fn from_etching( etching: &Etching, number: u32, + block_hash: &String, block_height: u64, tx_index: u32, tx_id: &String, @@ -72,6 +74,7 @@ impl DbRune { number: PgBigIntU32(number), name, spaced_name, + block_hash: block_hash[2..].to_string(), block_height: PgNumericU64(block_height), tx_index: PgBigIntU32(tx_index), tx_id: tx_id[2..].to_string(), @@ -106,6 +109,7 @@ impl DbRune { pub fn from_cenotaph_etching( rune: &Rune, number: u32, + block_hash: &String, block_height: u64, tx_index: u32, tx_id: &String, @@ -116,6 +120,7 @@ impl DbRune { name: rune.to_string(), spaced_name: rune.to_string(), number: PgBigIntU32(number), + block_hash: block_hash[2..].to_string(), block_height: PgNumericU64(block_height), tx_index: PgBigIntU32(tx_index), tx_id: tx_id[2..].to_string(), @@ -144,6 +149,7 @@ impl DbRune { number: row.get("number"), name: row.get("name"), spaced_name: row.get("spaced_name"), + block_hash: row.get("block_hash"), block_height: row.get("block_height"), tx_index: row.get("tx_index"), tx_id: row.get("tx_id"), @@ -201,6 +207,7 @@ mod test { turbo: false, }, 0, + &"00000000000000000000d2845e9e48d356e89fd3b2e1f3da668ffc04c7dfe298".to_string(), 1, 0, &"14e87956a6bb0f50df1515e85f1dcc4625a7e2ebeb08ab6db7d9211c7cf64fa3".to_string(), diff --git a/src/db/types/pg_numeric_u128.rs b/src/db/types/pg_numeric_u128.rs index 44a35e8..5e839ba 100644 --- a/src/db/types/pg_numeric_u128.rs +++ b/src/db/types/pg_numeric_u128.rs @@ -1,7 +1,7 @@ use std::{ error::Error, io::{Cursor, Read}, - ops::{Add, AddAssign}, + ops::AddAssign, }; use bytes::{BufMut, BytesMut}; From dba54a33a5dbb6a7282acbaf2aea8dd88b7e551c Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 28 Jun 2024 14:17:56 -0600 Subject: [PATCH 03/14] chore: improve logging --- src/config/file.rs | 22 ---- src/db/cache/index_cache.rs | 52 +++----- src/db/cache/transaction_cache.rs | 195 ++++++++++++------------------ src/db/models/db_rune.rs | 72 +++++------ 4 files changed, 122 insertions(+), 219 deletions(-) diff --git a/src/config/file.rs b/src/config/file.rs index fa7e1be..c90a729 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -1,33 +1,11 @@ use chainhook_sdk::observer::EventObserverConfigOverrides; -use super::Config; - #[derive(Deserialize, Debug, Clone)] pub struct ConfigFile { pub network: Option, pub postgres: PostgresConfigFile, pub resources: ResourcesConfigFile, } - -impl ConfigFile { - pub fn from_file_path(file_path: &str) -> Result { - unimplemented!() - } - - pub fn from_config_file(config_file: ConfigFile) -> Result { - unimplemented!() - } - - pub fn default( - devnet: bool, - testnet: bool, - mainnet: bool, - config_path: &Option, - ) -> Result { - unimplemented!() - } -} - #[derive(Deserialize, Debug, Clone)] pub struct LogConfigFile { pub runes_internals: Option, diff --git a/src/db/cache/index_cache.rs b/src/db/cache/index_cache.rs index a683b39..ca9be02 100644 --- a/src/db/cache/index_cache.rs +++ b/src/db/cache/index_cache.rs @@ -80,10 +80,10 @@ impl IndexCache { for entry in entries.iter() { debug!( ctx.expect_logger(), - "Assign unallocated {} {} at block {}", - entry.rune_id.clone(), + "Assign unallocated {} {} {}", + entry.rune_id, entry.amount.0, - entry.block_height.0 + self.tx_cache.location ); } self.add_ledger_entries_to_db_cache(&entries); @@ -97,13 +97,7 @@ impl IndexCache { db_tx: &mut Transaction<'_>, ctx: &Context, ) { - debug!( - ctx.expect_logger(), - "Runestone in tx {} ({}) at block {}", - self.tx_cache.tx_id, - self.tx_cache.tx_index, - self.tx_cache.block_height - ); + debug!(ctx.expect_logger(), "Runestone {}", self.tx_cache.location); self.scan_tx_input_rune_balance(tx_inputs, db_tx, ctx).await; self.tx_cache .apply_runestone_pointer(runestone, tx_outputs, ctx); @@ -116,13 +110,7 @@ impl IndexCache { db_tx: &mut Transaction<'_>, ctx: &Context, ) { - debug!( - ctx.expect_logger(), - "Cenotaph in tx {} ({}) at block {}", - self.tx_cache.tx_id, - self.tx_cache.tx_index, - self.tx_cache.block_height - ); + debug!(ctx.expect_logger(), "Cenotaph {}", self.tx_cache.location); self.scan_tx_input_rune_balance(tx_inputs, db_tx, ctx).await; let entries = self.tx_cache.apply_cenotaph_input_burn(cenotaph); self.add_ledger_entries_to_db_cache(&entries); @@ -137,9 +125,7 @@ impl IndexCache { let (rune_id, db_rune) = self.tx_cache.apply_etching(etching, self.next_rune_number); info!( ctx.expect_logger(), - "Etching {} at block {}", - db_rune.spaced_name.clone(), - db_rune.block_height.0 + "Etching {} ({}) {}", db_rune.spaced_name, db_rune.id, self.tx_cache.location ); self.db_cache.runes.push(db_rune.clone()); self.rune_cache.put(rune_id, db_rune); @@ -157,9 +143,7 @@ impl IndexCache { .apply_cenotaph_etching(rune, self.next_rune_number); info!( ctx.expect_logger(), - "Etching cenotaph {} at block {}", - db_rune.spaced_name.clone(), - db_rune.block_height.0 + "Etching cenotaph {} ({}) {}", db_rune.spaced_name, db_rune.id, self.tx_cache.location ); self.db_cache.runes.push(db_rune.clone()); self.rune_cache.put(rune_id, db_rune); @@ -175,17 +159,14 @@ impl IndexCache { let Some(db_rune) = self.get_cached_rune_by_rune_id(rune_id, db_tx, ctx).await else { warn!( ctx.expect_logger(), - "{}: rune {} not found for mint", self.tx_cache.tx_id, rune_id + "Rune {} not found for mint {}", rune_id, self.tx_cache.location ); return; }; let ledger_entry = self.tx_cache.apply_mint(&rune_id, &db_rune); info!( ctx.expect_logger(), - "Mint {} {} at block {}", - db_rune.spaced_name.clone(), - ledger_entry.amount.0, - ledger_entry.block_height.0 + "Mint {} {} {}", db_rune.spaced_name, ledger_entry.amount.0, self.tx_cache.location ); self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); } @@ -199,17 +180,17 @@ impl IndexCache { let Some(db_rune) = self.get_cached_rune_by_rune_id(rune_id, db_tx, ctx).await else { warn!( ctx.expect_logger(), - "{}: rune {} not found for cenotaph mint", self.tx_cache.tx_id, rune_id + "Rune {} not found for cenotaph mint {}", rune_id, self.tx_cache.location ); return; }; let ledger_entry = self.tx_cache.apply_cenotaph_mint(&rune_id, &db_rune); info!( ctx.expect_logger(), - "Mint cenotaph {} {} at block {}", - db_rune.spaced_name.clone(), + "Mint cenotaph {} {} {}", + db_rune.spaced_name, ledger_entry.amount.0, - ledger_entry.block_height.0 + self.tx_cache.location ); self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); } @@ -218,7 +199,7 @@ impl IndexCache { let Some(db_rune) = self.get_cached_rune_by_rune_id(&edict.id, db_tx, ctx).await else { warn!( ctx.expect_logger(), - "{}: rune {} not found for edict", self.tx_cache.tx_id, edict.id + "Rune {} not found for edict {}", edict.id, self.tx_cache.location ); return; }; @@ -226,10 +207,7 @@ impl IndexCache { for entry in entries.iter() { info!( ctx.expect_logger(), - "Edict {} {} at block {}", - db_rune.spaced_name.clone(), - entry.amount.0, - entry.block_height.0 + "Edict {} {} {}", db_rune.spaced_name, entry.amount.0, self.tx_cache.location ); } self.add_ledger_entries_to_db_cache(&entries); diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index e5a7453..cf1c451 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -1,4 +1,7 @@ -use std::collections::{HashMap, VecDeque}; +use std::{ + collections::{HashMap, VecDeque}, + fmt, +}; use bitcoin::{Address, Network, ScriptBuf}; use chainhook_sdk::{types::bitcoin::TxOut, utils::Context}; @@ -11,6 +14,35 @@ use crate::db::{ types::pg_numeric_u128::PgNumericU128, }; +#[derive(Debug, Clone)] +pub struct TransactionLocation { + pub network: Network, + pub block_hash: String, + pub block_height: u64, + pub timestamp: u32, + pub tx_index: u32, + pub tx_id: String, +} + +impl TransactionLocation { + pub fn rune_id(&self) -> RuneId { + RuneId { + block: self.block_height, + tx: self.tx_index, + } + } +} + +impl fmt::Display for TransactionLocation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "tx: {} ({}) @{}", + self.tx_id, self.tx_index, self.block_height + ) + } +} + #[derive(Debug, Clone)] pub struct InputRuneBalance { /// Previous owner of this balance. If this is `None`, it means the balance was just minted or premined. @@ -21,12 +53,7 @@ pub struct InputRuneBalance { /// Holds cached data relevant to a single transaction during indexing. pub struct TransactionCache { - network: Network, - pub block_hash: String, - pub block_height: u64, - pub tx_index: u32, - pub tx_id: String, - timestamp: u32, + pub location: TransactionLocation, /// Index of the ledger entry we're inserting next for this transaction. next_event_index: u32, /// Rune etched during this transaction @@ -52,13 +79,15 @@ impl TransactionCache { timestamp: u32, ) -> Self { TransactionCache { - network, - block_hash: block_hash.clone(), - block_height, - tx_index, + location: TransactionLocation { + network, + block_hash: block_hash.clone(), + block_height, + tx_id: tx_id.clone(), + tx_index, + timestamp, + }, next_event_index: 0, - tx_id: tx_id.clone(), - timestamp, etching: None, pointer: None, input_runes: HashMap::new(), @@ -89,7 +118,7 @@ impl TransactionCache { let Ok(bytes) = hex::decode(&output.script_pubkey[2..]) else { warn!( ctx.expect_logger(), - "{}: unable to decode script for output {}", self.tx_id, i + "Unable to decode script for output {} {}", i, self.location ); continue; }; @@ -104,7 +133,7 @@ impl TransactionCache { if first_eligible_output.is_none() { warn!( ctx.expect_logger(), - "{}: no eligible non-OP_RETURN output found", self.tx_id + "No eligible non-OP_RETURN output found {}", self.location ); } self.pointer = if runestone.pointer.is_some() { @@ -122,17 +151,13 @@ impl TransactionCache { for (rune_id, unallocated) in self.input_runes.iter() { for balance in unallocated { results.push(new_ledger_entry( + &self.location, balance.amount, *rune_id, - &self.block_hash, - self.block_height, - self.tx_index, - &self.tx_id, None, balance.address.as_ref(), None, DbLedgerOperation::Burn, - self.timestamp, &mut self.next_event_index, )); } @@ -147,12 +172,7 @@ impl TransactionCache { let mut results = vec![]; for (rune_id, unallocated) in self.input_runes.iter_mut() { results.extend(move_rune_balance_to_output( - self.network, - &self.block_hash, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, self.pointer, rune_id, unallocated, @@ -167,19 +187,8 @@ impl TransactionCache { } pub fn apply_etching(&mut self, etching: &Etching, number: u32) -> (RuneId, DbRune) { - let rune_id = RuneId { - block: self.block_height, - tx: self.tx_index, - }; - let db_rune = DbRune::from_etching( - etching, - number, - &self.block_hash, - self.block_height, - self.tx_index, - &self.tx_id, - self.timestamp, - ); + let rune_id = self.location.rune_id(); + let db_rune = DbRune::from_etching(etching, number, &self.location); self.etching = Some(db_rune.clone()); // Move pre-mined balance to input runes. if let Some(premine) = etching.premine { @@ -195,20 +204,9 @@ impl TransactionCache { } pub fn apply_cenotaph_etching(&mut self, rune: &Rune, number: u32) -> (RuneId, DbRune) { - let rune_id = RuneId { - block: self.block_height, - tx: self.tx_index, - }; + let rune_id = self.location.rune_id(); // If the runestone that produced the cenotaph contained an etching, the etched rune has supply zero and is unmintable. - let db_rune = DbRune::from_cenotaph_etching( - rune, - number, - &self.block_hash, - self.block_height, - self.tx_index, - &self.tx_id, - self.timestamp, - ); + let db_rune = DbRune::from_cenotaph_etching(rune, number, &self.location); self.etching = Some(db_rune.clone()); (rune_id, db_rune) } @@ -224,17 +222,13 @@ impl TransactionCache { }, ); new_ledger_entry( + &self.location, mint_amount.0, rune_id.clone(), - &self.block_hash, - self.block_height, - self.tx_index, - &self.tx_id, None, None, None, DbLedgerOperation::Mint, - self.timestamp, &mut self.next_event_index, ) } @@ -244,17 +238,13 @@ impl TransactionCache { let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); // This entry does not go in the input runes, it gets burned immediately. new_ledger_entry( + &self.location, mint_amount.0, rune_id.clone(), - &self.block_hash, - self.block_height, - self.tx_index, - &self.tx_id, None, None, None, DbLedgerOperation::Burn, - self.timestamp, &mut self.next_event_index, ) } @@ -265,7 +255,7 @@ impl TransactionCache { let Some(etching) = self.etching.as_ref() else { warn!( ctx.expect_logger(), - "{}: attempted edict for nonexistent rune 0:0", self.tx_id + "Attempted edict for nonexistent rune 0:0 {}", self.location ); return vec![]; }; @@ -277,7 +267,7 @@ impl TransactionCache { let Some(available_inputs) = self.input_runes.get_mut(&rune_id) else { warn!( ctx.expect_logger(), - "{}: no unallocated runes {} remain for edict", self.tx_id, edict.id + "No unallocated runes {} remain for edict {}", edict.id, self.location ); return vec![]; }; @@ -293,15 +283,10 @@ impl TransactionCache { // No eligible outputs means burn. warn!( ctx.expect_logger(), - "{}: no eligible outputs for edict on rune {}", self.tx_id, edict.id + "No eligible outputs for edict on rune {} {}", edict.id, self.location ); results.extend(move_rune_balance_to_output( - self.network, - &self.block_hash, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, None, // This will force a burn. &rune_id, available_inputs, @@ -331,12 +316,7 @@ impl TransactionCache { remainder -= 1; } results.extend(move_rune_balance_to_output( - self.network, - &self.block_hash, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, Some(output), &rune_id, available_inputs, @@ -351,12 +331,7 @@ impl TransactionCache { for output in output_keys { let amount = edict.amount.min(unallocated); results.extend(move_rune_balance_to_output( - self.network, - &self.block_hash, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, Some(output), &rune_id, available_inputs, @@ -375,12 +350,7 @@ impl TransactionCache { amount = unallocated; } results.extend(move_rune_balance_to_output( - self.network, - &self.block_hash, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, Some(edict.output), &rune_id, available_inputs, @@ -393,10 +363,10 @@ impl TransactionCache { _ => { warn!( ctx.expect_logger(), - "{}: edict for rune {} attempted move to nonexistent output {}", - self.tx_id, + "Edict for {} attempted move to nonexistent output {} {}", edict.id, - edict.output + edict.output, + self.location ); // TODO: Burn } @@ -417,32 +387,28 @@ impl TransactionCache { } fn new_ledger_entry( + location: &TransactionLocation, amount: u128, rune_id: RuneId, - block_hash: &String, - block_height: u64, - tx_index: u32, - tx_id: &String, output: Option, address: Option<&String>, receiver_address: Option<&String>, operation: DbLedgerOperation, - timestamp: u32, next_event_index: &mut u32, ) -> DbLedgerEntry { let entry = DbLedgerEntry::from_values( amount, rune_id, - block_hash, - block_height, - tx_index, + &location.block_hash, + location.block_height, + location.tx_index, *next_event_index, - tx_id, + &location.tx_id, output, address, receiver_address, operation, - timestamp, + location.timestamp, ); *next_event_index += 1; entry @@ -452,12 +418,7 @@ fn new_ledger_entry( /// Modifies `available_inputs` to consume balance that is already moved. If `amount` is zero, all remaining balances will be /// transferred. If `output` is `None`, the runes will be burnt. fn move_rune_balance_to_output( - network: Network, - block_hash: &String, - block_height: u64, - tx_id: &String, - tx_index: u32, - timestamp: u32, + location: &TransactionLocation, output: Option, rune_id: &RuneId, available_inputs: &mut VecDeque, @@ -470,12 +431,12 @@ fn move_rune_balance_to_output( // Who is this balance going to? let receiver_address = if let Some(output) = output { match eligible_outputs.get(&output) { - Some(script) => match Address::from_script(script, network) { + Some(script) => match Address::from_script(script, location.network) { Ok(address) => Some(address.to_string()), Err(e) => { warn!( ctx.expect_logger(), - "{}: unable to decode address for output {}, {}", tx_id, output, e + "Unable to decode address for output {}, {} {}", output, e, location ); None } @@ -483,7 +444,7 @@ fn move_rune_balance_to_output( None => { warn!( ctx.expect_logger(), - "{}: attempted move to non-eligible output {}", tx_id, output + "Attempted move to non-eligible output {} {}", output, location ); None } @@ -511,18 +472,14 @@ fn move_rune_balance_to_output( // Empty sender address means this balance was minted or premined, so we have no "send" entry to add. if let Some(sender_address) = input_bal.address.clone() { results.push(new_ledger_entry( + location, balance_taken, *rune_id, - block_hash, - block_height, - tx_index, - tx_id, output, Some(&sender_address), // Depending on the logic above, this might be a normal "send" or a "burn" if the target output was not found. receiver_address.as_ref(), operation.clone(), - timestamp, next_event_index, )); } @@ -542,17 +499,13 @@ fn move_rune_balance_to_output( // Add the "receive" entry, if applicable. if receiver_address.is_some() && total_sent > 0 { results.push(new_ledger_entry( + location, total_sent, *rune_id, - block_hash, - block_height, - tx_index, - &tx_id, output, receiver_address.as_ref(), None, DbLedgerOperation::Receive, - timestamp, next_event_index, )); } diff --git a/src/db/models/db_rune.rs b/src/db/models/db_rune.rs index 4db44ef..eb86b16 100644 --- a/src/db/models/db_rune.rs +++ b/src/db/models/db_rune.rs @@ -1,9 +1,12 @@ use ordinals::{Etching, Rune, RuneId, SpacedRune}; use tokio_postgres::Row; -use crate::db::types::{ - pg_bigint_u32::PgBigIntU32, pg_numeric_u128::PgNumericU128, pg_numeric_u64::PgNumericU64, - pg_smallint_u8::PgSmallIntU8, +use crate::db::{ + cache::transaction_cache::TransactionLocation, + types::{ + pg_bigint_u32::PgBigIntU32, pg_numeric_u128::PgNumericU128, pg_numeric_u64::PgNumericU64, + pg_smallint_u8::PgSmallIntU8, + }, }; /// A row in the `runes` table. @@ -36,18 +39,10 @@ pub struct DbRune { } impl DbRune { - pub fn from_etching( - etching: &Etching, - number: u32, - block_hash: &String, - block_height: u64, - tx_index: u32, - tx_id: &String, - timestamp: u32, - ) -> Self { + pub fn from_etching(etching: &Etching, number: u32, location: &TransactionLocation) -> Self { let rune = etching .rune - .unwrap_or(Rune::reserved(block_height, tx_index)); + .unwrap_or(Rune::reserved(location.block_height, location.tx_index)); let spaced_name = if let Some(spacers) = etching.spacers { let spaced_rune = SpacedRune::new(rune, spacers); spaced_rune.to_string() @@ -70,14 +65,14 @@ impl DbRune { terms_offset_end = terms.offset.1.map(|i| PgNumericU64(i)); } DbRune { - id: format!("{}:{}", block_height, tx_index), + id: format!("{}:{}", location.block_height, location.tx_index), number: PgBigIntU32(number), name, spaced_name, - block_hash: block_hash[2..].to_string(), - block_height: PgNumericU64(block_height), - tx_index: PgBigIntU32(tx_index), - tx_id: tx_id[2..].to_string(), + block_hash: location.block_hash[2..].to_string(), + block_height: PgNumericU64(location.block_height), + tx_index: PgBigIntU32(location.tx_index), + tx_id: location.tx_id[2..].to_string(), divisibility: etching .divisibility .map(|i| PgSmallIntU8(i)) @@ -102,28 +97,20 @@ impl DbRune { burned: PgNumericU128(0), total_burns: PgBigIntU32(0), total_operations: PgBigIntU32(0), - timestamp: PgBigIntU32(timestamp), + timestamp: PgBigIntU32(location.timestamp), } } - pub fn from_cenotaph_etching( - rune: &Rune, - number: u32, - block_hash: &String, - block_height: u64, - tx_index: u32, - tx_id: &String, - timestamp: u32, - ) -> Self { + pub fn from_cenotaph_etching(rune: &Rune, number: u32, location: &TransactionLocation) -> Self { DbRune { - id: format!("{}:{}", block_height, tx_index), + id: format!("{}:{}", location.block_height, location.tx_index), name: rune.to_string(), spaced_name: rune.to_string(), number: PgBigIntU32(number), - block_hash: block_hash[2..].to_string(), - block_height: PgNumericU64(block_height), - tx_index: PgBigIntU32(tx_index), - tx_id: tx_id[2..].to_string(), + block_hash: location.block_hash[2..].to_string(), + block_height: PgNumericU64(location.block_height), + tx_index: PgBigIntU32(location.tx_index), + tx_id: location.tx_id[2..].to_string(), divisibility: PgSmallIntU8(0), premine: PgNumericU128(0), symbol: "".to_string(), @@ -139,7 +126,7 @@ impl DbRune { burned: PgNumericU128(0), total_burns: PgBigIntU32(0), total_operations: PgBigIntU32(0), - timestamp: PgBigIntU32(timestamp), + timestamp: PgBigIntU32(location.timestamp), } } @@ -186,6 +173,8 @@ mod test { use ordinals::{Etching, SpacedRune, Terms}; + use crate::db::cache::transaction_cache::TransactionLocation; + use super::DbRune; #[test] @@ -207,11 +196,16 @@ mod test { turbo: false, }, 0, - &"00000000000000000000d2845e9e48d356e89fd3b2e1f3da668ffc04c7dfe298".to_string(), - 1, - 0, - &"14e87956a6bb0f50df1515e85f1dcc4625a7e2ebeb08ab6db7d9211c7cf64fa3".to_string(), - 0, + &TransactionLocation { + network: bitcoin::Network::Bitcoin, + block_hash: "00000000000000000000d2845e9e48d356e89fd3b2e1f3da668ffc04c7dfe298" + .to_string(), + block_height: 1, + tx_index: 0, + tx_id: "14e87956a6bb0f50df1515e85f1dcc4625a7e2ebeb08ab6db7d9211c7cf64fa3" + .to_string(), + timestamp: 0, + }, ); assert!(db_rune.name == "UNCOMMONGOODS"); assert!(db_rune.spaced_name == "UNCOMMON•GOODS"); From 85893782065829a643ac8d1f9ae43ee70eb0426b Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Fri, 28 Jun 2024 16:10:46 -0600 Subject: [PATCH 04/14] fix: receive/send operation order --- src/db/cache/index_cache.rs | 27 +--- src/db/cache/transaction_cache.rs | 180 ++++++++++++++++++++++++--- src/db/models/db_ledger_operation.rs | 8 +- 3 files changed, 174 insertions(+), 41 deletions(-) diff --git a/src/db/cache/index_cache.rs b/src/db/cache/index_cache.rs index ca9be02..450a16b 100644 --- a/src/db/cache/index_cache.rs +++ b/src/db/cache/index_cache.rs @@ -77,15 +77,6 @@ impl IndexCache { /// Finalizes the current transaction index cache. pub fn end_transaction(&mut self, _db_tx: &mut Transaction<'_>, ctx: &Context) { let entries = self.tx_cache.allocate_remaining_balances(ctx); - for entry in entries.iter() { - debug!( - ctx.expect_logger(), - "Assign unallocated {} {} {}", - entry.rune_id, - entry.amount.0, - self.tx_cache.location - ); - } self.add_ledger_entries_to_db_cache(&entries); } @@ -163,11 +154,7 @@ impl IndexCache { ); return; }; - let ledger_entry = self.tx_cache.apply_mint(&rune_id, &db_rune); - info!( - ctx.expect_logger(), - "Mint {} {} {}", db_rune.spaced_name, ledger_entry.amount.0, self.tx_cache.location - ); + let ledger_entry = self.tx_cache.apply_mint(&rune_id, &db_rune, ctx); self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); } @@ -184,14 +171,7 @@ impl IndexCache { ); return; }; - let ledger_entry = self.tx_cache.apply_cenotaph_mint(&rune_id, &db_rune); - info!( - ctx.expect_logger(), - "Mint cenotaph {} {} {}", - db_rune.spaced_name, - ledger_entry.amount.0, - self.tx_cache.location - ); + let ledger_entry = self.tx_cache.apply_cenotaph_mint(&rune_id, &db_rune, ctx); self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); } @@ -278,7 +258,8 @@ impl IndexCache { } } - self.tx_cache.set_input_rune_balances(final_input_runes); + self.tx_cache + .set_input_rune_balances(final_input_runes, ctx); } /// Take ledger entries returned by the `TransactionCache` and add them to the `DbCache`. Update global balances and counters diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index cf1c451..04c4906 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, VecDeque}, - fmt, + fmt, vec, }; use bitcoin::{Address, Network, ScriptBuf}; @@ -100,7 +100,16 @@ impl TransactionCache { pub fn set_input_rune_balances( &mut self, input_runes: HashMap>, + ctx: &Context, ) { + for (rune_id, vec) in input_runes.iter() { + for input in vec.iter() { + debug!( + ctx.expect_logger(), + "Input {} {:?} ({}) {}", rune_id, input.address, input.amount, self.location + ); + } + } self.input_runes = input_runes; } @@ -171,13 +180,23 @@ impl TransactionCache { pub fn allocate_remaining_balances(&mut self, ctx: &Context) -> Vec { let mut results = vec![]; for (rune_id, unallocated) in self.input_runes.iter_mut() { + for input in unallocated.iter() { + debug!( + ctx.expect_logger(), + "Assign unallocated {} {:?} ({}) {}", + rune_id, + input.address, + input.amount, + self.location + ); + } results.extend(move_rune_balance_to_output( &self.location, self.pointer, rune_id, unallocated, &self.eligible_outputs, - 0, + 0, // All of it &mut self.next_event_index, ctx, )); @@ -211,9 +230,18 @@ impl TransactionCache { (rune_id, db_rune) } - pub fn apply_mint(&mut self, rune_id: &RuneId, db_rune: &DbRune) -> DbLedgerEntry { + pub fn apply_mint( + &mut self, + rune_id: &RuneId, + db_rune: &DbRune, + ctx: &Context, + ) -> DbLedgerEntry { // TODO: What's the default mint amount if none was provided? let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + info!( + ctx.expect_logger(), + "MINT {} ({}) {} {}", rune_id, db_rune.spaced_name, mint_amount.0, self.location + ); self.add_input_runes( rune_id, InputRuneBalance { @@ -233,9 +261,18 @@ impl TransactionCache { ) } - pub fn apply_cenotaph_mint(&mut self, rune_id: &RuneId, db_rune: &DbRune) -> DbLedgerEntry { + pub fn apply_cenotaph_mint( + &mut self, + rune_id: &RuneId, + db_rune: &DbRune, + ctx: &Context, + ) -> DbLedgerEntry { // TODO: What's the default mint amount if none was provided? let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + info!( + ctx.expect_logger(), + "CENOTAPH MINT {} {} {}", db_rune.spaced_name, mint_amount.0, self.location + ); // This entry does not go in the input runes, it gets burned immediately. new_ledger_entry( &self.location, @@ -368,7 +405,16 @@ impl TransactionCache { edict.output, self.location ); - // TODO: Burn + results.extend(move_rune_balance_to_output( + &self.location, + None, // Burn. + &rune_id, + available_inputs, + &self.eligible_outputs, + edict.amount, + &mut self.next_event_index, + ctx, + )); } } } @@ -457,8 +503,10 @@ fn move_rune_balance_to_output( } else { DbLedgerOperation::Burn }; - // Produce the `send` ledger entries by taking balance from the available inputs until the total amount is satisfied. + + // Gather balance to be received by taking it from the available inputs until the amount to move is satisfied. let mut total_sent = 0; + let mut senders = vec![]; loop { let Some(input_bal) = available_inputs.pop_front() else { // Unallocated balance ran out. @@ -471,17 +519,7 @@ fn move_rune_balance_to_output( }; // Empty sender address means this balance was minted or premined, so we have no "send" entry to add. if let Some(sender_address) = input_bal.address.clone() { - results.push(new_ledger_entry( - location, - balance_taken, - *rune_id, - output, - Some(&sender_address), - // Depending on the logic above, this might be a normal "send" or a "burn" if the target output was not found. - receiver_address.as_ref(), - operation.clone(), - next_event_index, - )); + senders.push((balance_taken, sender_address)); } if balance_taken < input_bal.amount { // There's still some balance left on this input, keep it for later. @@ -508,6 +546,114 @@ fn move_rune_balance_to_output( DbLedgerOperation::Receive, next_event_index, )); + info!( + ctx.expect_logger(), + "{} {} ({}) {} {}", + DbLedgerOperation::Receive, + rune_id, + total_sent, + receiver_address.as_ref().unwrap(), + location + ); + } + // Add the "send"/"burn" entries. + for (balance_taken, sender_address) in senders.iter() { + results.push(new_ledger_entry( + location, + *balance_taken, + *rune_id, + output, + Some(sender_address), + receiver_address.as_ref(), + operation.clone(), + next_event_index, + )); + info!( + ctx.expect_logger(), + "{} {} ({}) {} -> {:?} {}", + operation, + rune_id, + balance_taken, + sender_address, + receiver_address, + location + ); } results } + +#[cfg(test)] +mod test { + use std::collections::{HashMap, VecDeque}; + + use bitcoin::ScriptBuf; + use chainhook_sdk::utils::Context; + use ordinals::RuneId; + + use crate::db::models::db_ledger_operation::DbLedgerOperation; + + use super::{move_rune_balance_to_output, InputRuneBalance, TransactionLocation}; + + #[test] + fn receives_are_registered_first() { + let logger = hiro_system_kit::log::setup_logger(); + let _guard = hiro_system_kit::log::setup_global_logger(logger.clone()); + let ctx = Context { + logger: Some(logger), + tracer: false, + }; + let location = TransactionLocation { + network: bitcoin::Network::Bitcoin, + block_hash: "00000000000000000002c0cc73626b56fb3ee1ce605b0ce125cc4fb58775a0a9" + .to_string(), + block_height: 840002, + timestamp: 0, + tx_id: "37cd29676d626492cd9f20c60bc4f20347af9c0d91b5689ed75c05bb3e2f73ef".to_string(), + tx_index: 2936, + }; + let mut available_inputs = VecDeque::new(); + // An input from a previous tx + available_inputs.push_back(InputRuneBalance { + address: Some( + "bc1p8zxlhgdsq6dmkzk4ammzcx55c3hfrg69ftx0gzlnfwq0wh38prds0nzqwf".to_string(), + ), + amount: 1000, + }); + // A mint + available_inputs.push_back(InputRuneBalance { + address: None, + amount: 1000, + }); + let mut eligible_outputs = HashMap::new(); + eligible_outputs.insert( + 0u32, + ScriptBuf::from_hex( + "5120388dfba1b0069bbb0ad5eef62c1a94c46e91a3454accf40bf34b80f75e2708db", + ) + .unwrap(), + ); + let mut next_event_index = 0; + let results = move_rune_balance_to_output( + &location, + Some(0), + &RuneId::new(840000, 25).unwrap(), + &mut available_inputs, + &eligible_outputs, + 0, + &mut next_event_index, + &ctx, + ); + + let receive = results.get(0).unwrap(); + assert_eq!(receive.event_index.0, 0u32); + assert_eq!(receive.operation, DbLedgerOperation::Receive); + assert_eq!(receive.amount.0, 2000u128); + + let send = results.get(1).unwrap(); + assert_eq!(send.event_index.0, 1u32); + assert_eq!(send.operation, DbLedgerOperation::Send); + assert_eq!(send.amount.0, 1000u128); + + assert_eq!(results.len(), 2); + } +} diff --git a/src/db/models/db_ledger_operation.rs b/src/db/models/db_ledger_operation.rs index b67ce23..372790a 100644 --- a/src/db/models/db_ledger_operation.rs +++ b/src/db/models/db_ledger_operation.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::{error::Error, fmt}; use bytes::BytesMut; use tokio_postgres::types::{to_sql_checked, FromSql, IsNull, ToSql, Type}; @@ -12,6 +12,12 @@ pub enum DbLedgerOperation { Receive, } +impl fmt::Display for DbLedgerOperation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.as_str().to_uppercase()) + } +} + impl DbLedgerOperation { pub fn as_str(&self) -> &str { match self { From 909b59e168c5b2b70b6acc7ce22ac74f5e54b1e8 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 14:11:55 -0600 Subject: [PATCH 05/14] fix: consider mint caps --- migrations/V1__runes.sql | 6 +-- src/db/cache/index_cache.rs | 70 ++++++++++++++++++++++++++++--- src/db/cache/transaction_cache.rs | 46 ++++++++++++++------ src/db/mod.rs | 26 ++++++++++++ src/db/models/db_rune.rs | 18 ++++---- src/db/models/db_rune_update.rs | 26 ++++++------ src/db/types/pg_numeric_u128.rs | 6 +++ 7 files changed, 154 insertions(+), 44 deletions(-) diff --git a/migrations/V1__runes.sql b/migrations/V1__runes.sql index 1a7f88f..b0a4eb8 100644 --- a/migrations/V1__runes.sql +++ b/migrations/V1__runes.sql @@ -18,10 +18,10 @@ CREATE TABLE IF NOT EXISTS runes ( terms_offset_end NUMERIC, turbo BOOLEAN NOT NULL DEFAULT FALSE, minted NUMERIC NOT NULL DEFAULT 0, - total_mints BIGINT NOT NULL DEFAULT 0, + total_mints NUMERIC NOT NULL DEFAULT 0, burned NUMERIC NOT NULL DEFAULT 0, - total_burns BIGINT NOT NULL DEFAULT 0, - total_operations BIGINT NOT NULL DEFAULT 0, + total_burns NUMERIC NOT NULL DEFAULT 0, + total_operations NUMERIC NOT NULL DEFAULT 0, timestamp BIGINT NOT NULL ); CREATE INDEX runes_block_height_tx_index_index ON runes (block_height, tx_index); diff --git a/src/db/cache/index_cache.rs b/src/db/cache/index_cache.rs index 450a16b..0673462 100644 --- a/src/db/cache/index_cache.rs +++ b/src/db/cache/index_cache.rs @@ -18,7 +18,7 @@ use crate::db::{ db_balance_update::DbBalanceUpdate, db_ledger_entry::DbLedgerEntry, db_ledger_operation::DbLedgerOperation, db_rune::DbRune, db_rune_update::DbRuneUpdate, }, - pg_get_missed_input_rune_balances, pg_get_rune_by_id, + pg_get_missed_input_rune_balances, pg_get_rune_by_id, pg_get_rune_total_mints, }; use super::{ @@ -34,6 +34,8 @@ pub struct IndexCache { next_rune_number: u32, /// LRU cache for runes. rune_cache: LruCache, + /// LRU cache for total mints for runes. + rune_total_mints_cache: LruCache, /// LRU cache for outputs with rune balances. output_cache: LruCache<(String, u32), HashMap>>, /// Holds a single transaction's rune cache. Must be cleared every time a new transaction is processed. @@ -49,6 +51,7 @@ impl IndexCache { network, next_rune_number: max_rune_number + 1, rune_cache: LruCache::new(cap), + rune_total_mints_cache: LruCache::new(cap), output_cache: LruCache::new(cap), tx_cache: TransactionCache::new(network, &"".to_string(), 1, 0, &"".to_string(), 0), db_cache: DbCache::new(), @@ -88,7 +91,10 @@ impl IndexCache { db_tx: &mut Transaction<'_>, ctx: &Context, ) { - debug!(ctx.expect_logger(), "Runestone {}", self.tx_cache.location); + debug!( + ctx.expect_logger(), + "{:?} {}", runestone, self.tx_cache.location + ); self.scan_tx_input_rune_balance(tx_inputs, db_tx, ctx).await; self.tx_cache .apply_runestone_pointer(runestone, tx_outputs, ctx); @@ -154,8 +160,21 @@ impl IndexCache { ); return; }; - let ledger_entry = self.tx_cache.apply_mint(&rune_id, &db_rune, ctx); - self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); + let total_mints = self + .get_cached_rune_total_mints(rune_id, db_tx, ctx) + .await + .unwrap_or(0); + if let Some(ledger_entry) = self + .tx_cache + .apply_mint(&rune_id, total_mints, &db_rune, ctx) + { + self.add_ledger_entries_to_db_cache(&vec![ledger_entry.clone()]); + if let Some(total) = self.rune_total_mints_cache.get_mut(rune_id) { + *total += 1; + } else { + self.rune_total_mints_cache.put(rune_id.clone(), 1); + } + } } pub async fn apply_cenotaph_mint( @@ -171,8 +190,21 @@ impl IndexCache { ); return; }; - let ledger_entry = self.tx_cache.apply_cenotaph_mint(&rune_id, &db_rune, ctx); - self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); + let total_mints = self + .get_cached_rune_total_mints(rune_id, db_tx, ctx) + .await + .unwrap_or(0); + if let Some(ledger_entry) = + self.tx_cache + .apply_cenotaph_mint(&rune_id, total_mints, &db_rune, ctx) + { + self.add_ledger_entries_to_db_cache(&vec![ledger_entry]); + if let Some(total) = self.rune_total_mints_cache.get_mut(rune_id) { + *total += 1; + } else { + self.rune_total_mints_cache.put(rune_id.clone(), 1); + } + } } pub async fn apply_edict(&mut self, edict: &Edict, db_tx: &mut Transaction<'_>, ctx: &Context) { @@ -215,6 +247,32 @@ impl IndexCache { return Some(db_rune); } + async fn get_cached_rune_total_mints( + &mut self, + rune_id: &RuneId, + db_tx: &mut Transaction<'_>, + ctx: &Context, + ) -> Option { + let real_rune_id = if rune_id.block == 0 && rune_id.tx == 0 { + let Some(etching) = self.tx_cache.etching.as_ref() else { + return None; + }; + RuneId::from_str(etching.id.as_str()).unwrap() + } else { + rune_id.clone() + }; + if let Some(total) = self.rune_total_mints_cache.get(&real_rune_id) { + return Some(*total); + } + // Cache miss, look in DB. + self.db_cache.flush(db_tx, ctx).await; + let Some(total) = pg_get_rune_total_mints(rune_id, db_tx, ctx).await else { + return None; + }; + self.rune_total_mints_cache.put(rune_id.clone(), total); + return Some(total); + } + /// Takes all transaction inputs and transform them into rune balances to be allocated. async fn scan_tx_input_rune_balance( &mut self, diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index 04c4906..a0b228b 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -233,57 +233,77 @@ impl TransactionCache { pub fn apply_mint( &mut self, rune_id: &RuneId, + total_mints: u128, db_rune: &DbRune, ctx: &Context, - ) -> DbLedgerEntry { + ) -> Option { // TODO: What's the default mint amount if none was provided? - let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + let terms_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + if let Some(terms_cap) = db_rune.terms_cap { + if total_mints >= terms_cap.0 { + debug!( + ctx.expect_logger(), + "Mint {} exceeds mint cap, ignoring {}", rune_id, self.location + ); + return None; + } + } info!( ctx.expect_logger(), - "MINT {} ({}) {} {}", rune_id, db_rune.spaced_name, mint_amount.0, self.location + "MINT {} ({}) {} {}", rune_id, db_rune.spaced_name, terms_amount.0, self.location ); self.add_input_runes( rune_id, InputRuneBalance { address: None, - amount: mint_amount.0, + amount: terms_amount.0, }, ); - new_ledger_entry( + Some(new_ledger_entry( &self.location, - mint_amount.0, + terms_amount.0, rune_id.clone(), None, None, None, DbLedgerOperation::Mint, &mut self.next_event_index, - ) + )) } pub fn apply_cenotaph_mint( &mut self, rune_id: &RuneId, + total_mints: u128, db_rune: &DbRune, ctx: &Context, - ) -> DbLedgerEntry { + ) -> Option { // TODO: What's the default mint amount if none was provided? - let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + let terms_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + if let Some(terms_cap) = db_rune.terms_cap { + if total_mints >= terms_cap.0 { + debug!( + ctx.expect_logger(), + "Cenotaph mint {} exceeds mint cap, ignoring {}", rune_id, self.location + ); + return None; + } + } info!( ctx.expect_logger(), - "CENOTAPH MINT {} {} {}", db_rune.spaced_name, mint_amount.0, self.location + "CENOTAPH MINT {} {} {}", db_rune.spaced_name, terms_amount.0, self.location ); // This entry does not go in the input runes, it gets burned immediately. - new_ledger_entry( + Some(new_ledger_entry( &self.location, - mint_amount.0, + terms_amount.0, rune_id.clone(), None, None, None, DbLedgerOperation::Burn, &mut self.next_event_index, - ) + )) } pub fn apply_edict(&mut self, edict: &Edict, ctx: &Context) -> Vec { diff --git a/src/db/mod.rs b/src/db/mod.rs index 54f6fdb..d76b807 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -337,6 +337,32 @@ pub async fn pg_get_rune_by_id( Some(DbRune::from_pg_row(&row)) } +pub async fn pg_get_rune_total_mints( + id: &RuneId, + db_tx: &mut Transaction<'_>, + ctx: &Context, +) -> Option { + let row = match db_tx + .query_opt("SELECT total_mints FROM runes WHERE id = $1", &[&id.to_string()]) + .await + { + Ok(row) => row, + Err(e) => { + error!( + ctx.expect_logger(), + "error retrieving rune minted total: {}", + e.to_string() + ); + panic!(); + } + }; + let Some(row) = row else { + return None; + }; + let minted: PgNumericU128 = row.get("total_mints"); + Some(minted.0) +} + pub async fn pg_get_missed_input_rune_balances( outputs: Vec<(u32, String, u32)>, db_tx: &mut Transaction<'_>, diff --git a/src/db/models/db_rune.rs b/src/db/models/db_rune.rs index eb86b16..bba0428 100644 --- a/src/db/models/db_rune.rs +++ b/src/db/models/db_rune.rs @@ -31,10 +31,10 @@ pub struct DbRune { pub terms_offset_end: Option, pub turbo: bool, pub minted: PgNumericU128, - pub total_mints: PgBigIntU32, + pub total_mints: PgNumericU128, pub burned: PgNumericU128, - pub total_burns: PgBigIntU32, - pub total_operations: PgBigIntU32, + pub total_burns: PgNumericU128, + pub total_operations: PgNumericU128, pub timestamp: PgBigIntU32, } @@ -93,10 +93,10 @@ impl DbRune { terms_offset_end, turbo: etching.turbo, minted: PgNumericU128(0), - total_mints: PgBigIntU32(0), + total_mints: PgNumericU128(0), burned: PgNumericU128(0), - total_burns: PgBigIntU32(0), - total_operations: PgBigIntU32(0), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(0), timestamp: PgBigIntU32(location.timestamp), } } @@ -122,10 +122,10 @@ impl DbRune { terms_offset_end: None, turbo: false, minted: PgNumericU128(0), - total_mints: PgBigIntU32(0), + total_mints: PgNumericU128(0), burned: PgNumericU128(0), - total_burns: PgBigIntU32(0), - total_operations: PgBigIntU32(0), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(0), timestamp: PgBigIntU32(location.timestamp), } } diff --git a/src/db/models/db_rune_update.rs b/src/db/models/db_rune_update.rs index e7798cd..72c2c02 100644 --- a/src/db/models/db_rune_update.rs +++ b/src/db/models/db_rune_update.rs @@ -1,14 +1,14 @@ -use crate::db::types::{pg_bigint_u32::PgBigIntU32, pg_numeric_u128::PgNumericU128}; +use crate::db::types::pg_numeric_u128::PgNumericU128; /// An update to a rune that affects its total counts. #[derive(Debug, Clone)] pub struct DbRuneUpdate { pub id: String, pub minted: PgNumericU128, - pub total_mints: PgBigIntU32, + pub total_mints: PgNumericU128, pub burned: PgNumericU128, - pub total_burns: PgBigIntU32, - pub total_operations: PgBigIntU32, + pub total_burns: PgNumericU128, + pub total_operations: PgNumericU128, } impl DbRuneUpdate { @@ -16,10 +16,10 @@ impl DbRuneUpdate { DbRuneUpdate { id, minted: amount, - total_mints: PgBigIntU32(1), + total_mints: PgNumericU128(1), burned: PgNumericU128(0), - total_burns: PgBigIntU32(0), - total_operations: PgBigIntU32(1), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(1), } } @@ -27,10 +27,10 @@ impl DbRuneUpdate { DbRuneUpdate { id, minted: PgNumericU128(0), - total_mints: PgBigIntU32(0), + total_mints: PgNumericU128(0), burned: amount, - total_burns: PgBigIntU32(1), - total_operations: PgBigIntU32(1), + total_burns: PgNumericU128(1), + total_operations: PgNumericU128(1), } } @@ -38,10 +38,10 @@ impl DbRuneUpdate { DbRuneUpdate { id, minted: PgNumericU128(0), - total_mints: PgBigIntU32(0), + total_mints: PgNumericU128(0), burned: PgNumericU128(0), - total_burns: PgBigIntU32(0), - total_operations: PgBigIntU32(1), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(1), } } } diff --git a/src/db/types/pg_numeric_u128.rs b/src/db/types/pg_numeric_u128.rs index 5e839ba..8a9b200 100644 --- a/src/db/types/pg_numeric_u128.rs +++ b/src/db/types/pg_numeric_u128.rs @@ -101,6 +101,12 @@ impl AddAssign for PgNumericU128 { } } +impl AddAssign for PgNumericU128 { + fn add_assign(&mut self, other: u128) { + self.0 += other; + } +} + #[cfg(test)] mod test { use test_case::test_case; From 97263a116b885d3c0b63b6b2ae5e61bf8c366e46 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 14:23:19 -0600 Subject: [PATCH 06/14] fix: consider mint terms block height --- src/db/cache/index_cache.rs | 5 ++- src/db/cache/transaction_cache.rs | 59 ++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/db/cache/index_cache.rs b/src/db/cache/index_cache.rs index 0673462..a63f601 100644 --- a/src/db/cache/index_cache.rs +++ b/src/db/cache/index_cache.rs @@ -107,7 +107,10 @@ impl IndexCache { db_tx: &mut Transaction<'_>, ctx: &Context, ) { - debug!(ctx.expect_logger(), "Cenotaph {}", self.tx_cache.location); + debug!( + ctx.expect_logger(), + "{:?} {}", cenotaph, self.tx_cache.location + ); self.scan_tx_input_rune_balance(tx_inputs, db_tx, ctx).await; let entries = self.tx_cache.apply_cenotaph_input_burn(cenotaph); self.add_ledger_entries_to_db_cache(&entries); diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index a0b228b..764c28c 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -239,14 +239,12 @@ impl TransactionCache { ) -> Option { // TODO: What's the default mint amount if none was provided? let terms_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); - if let Some(terms_cap) = db_rune.terms_cap { - if total_mints >= terms_cap.0 { - debug!( - ctx.expect_logger(), - "Mint {} exceeds mint cap, ignoring {}", rune_id, self.location - ); - return None; - } + if !is_valid_mint(db_rune, total_mints, &self.location) { + debug!( + ctx.expect_logger(), + "Invalid mint {} {}", rune_id, self.location + ); + return None; } info!( ctx.expect_logger(), @@ -280,14 +278,12 @@ impl TransactionCache { ) -> Option { // TODO: What's the default mint amount if none was provided? let terms_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); - if let Some(terms_cap) = db_rune.terms_cap { - if total_mints >= terms_cap.0 { - debug!( - ctx.expect_logger(), - "Cenotaph mint {} exceeds mint cap, ignoring {}", rune_id, self.location - ); - return None; - } + if !is_valid_mint(db_rune, total_mints, &self.location) { + debug!( + ctx.expect_logger(), + "Invalid mint {} {}", rune_id, self.location + ); + return None; } info!( ctx.expect_logger(), @@ -452,6 +448,37 @@ impl TransactionCache { } } +/// Determines if a mint is valid depending on the rune's mint terms. +fn is_valid_mint(db_rune: &DbRune, total_mints: u128, location: &TransactionLocation) -> bool { + if let Some(terms_cap) = db_rune.terms_cap { + if total_mints >= terms_cap.0 { + return false; + } + } + if let Some(terms_height_start) = db_rune.terms_height_start { + if location.block_height < terms_height_start.0 { + return false; + } + } + if let Some(terms_height_end) = db_rune.terms_height_end { + if location.block_height > terms_height_end.0 { + return false; + } + } + if let Some(terms_offset_start) = db_rune.terms_offset_start { + if location.block_height < db_rune.block_height.0 + terms_offset_start.0 { + return false; + } + } + if let Some(terms_offset_end) = db_rune.terms_offset_end { + if location.block_height > db_rune.block_height.0 + terms_offset_end.0 { + return false; + } + } + true +} + +/// Creates a new ledger entry. fn new_ledger_entry( location: &TransactionLocation, amount: u128, From 16280d780e39116e07c816f4a39f06469d192c41 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 18:42:16 -0600 Subject: [PATCH 07/14] test: mint terms --- src/db/cache/mod.rs | 1 + src/db/cache/transaction_cache.rs | 77 +++++++++++++++++----------- src/db/cache/transaction_location.rs | 52 +++++++++++++++++++ src/db/models/db_rune.rs | 61 +++++++++++++++++++++- 4 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 src/db/cache/transaction_location.rs diff --git a/src/db/cache/mod.rs b/src/db/cache/mod.rs index f8fc10d..64615d0 100644 --- a/src/db/cache/mod.rs +++ b/src/db/cache/mod.rs @@ -9,6 +9,7 @@ use super::pg_get_max_rune_number; pub mod db_cache; pub mod index_cache; pub mod transaction_cache; +pub mod transaction_location; /// Creates a blank index cache pointing to the correct next rune number to etch. pub async fn new_index_cache(config: &Config, pg_client: &mut Client, ctx: &Context) -> IndexCache { diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index 764c28c..c4d5f14 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, VecDeque}, - fmt, vec, + vec, }; use bitcoin::{Address, Network, ScriptBuf}; @@ -14,34 +14,7 @@ use crate::db::{ types::pg_numeric_u128::PgNumericU128, }; -#[derive(Debug, Clone)] -pub struct TransactionLocation { - pub network: Network, - pub block_hash: String, - pub block_height: u64, - pub timestamp: u32, - pub tx_index: u32, - pub tx_id: String, -} - -impl TransactionLocation { - pub fn rune_id(&self) -> RuneId { - RuneId { - block: self.block_height, - tx: self.tx_index, - } - } -} - -impl fmt::Display for TransactionLocation { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!( - f, - "tx: {} ({}) @{}", - self.tx_id, self.tx_index, self.block_height - ) - } -} +use super::transaction_location::TransactionLocation; #[derive(Debug, Clone)] pub struct InputRuneBalance { @@ -632,14 +605,19 @@ fn move_rune_balance_to_output( #[cfg(test)] mod test { use std::collections::{HashMap, VecDeque}; + use test_case::test_case; use bitcoin::ScriptBuf; use chainhook_sdk::utils::Context; use ordinals::RuneId; - use crate::db::models::db_ledger_operation::DbLedgerOperation; + use crate::db::{ + cache::transaction_location::TransactionLocation, + models::{db_ledger_operation::DbLedgerOperation, db_rune::DbRune}, + types::{pg_numeric_u128::PgNumericU128, pg_numeric_u64::PgNumericU64}, + }; - use super::{move_rune_balance_to_output, InputRuneBalance, TransactionLocation}; + use super::{is_valid_mint, move_rune_balance_to_output, InputRuneBalance}; #[test] fn receives_are_registered_first() { @@ -703,4 +681,41 @@ mod test { assert_eq!(results.len(), 2); } + + #[test_case(840000 => false; "early block")] + #[test_case(840500 => false; "late block")] + #[test_case(840150 => true; "block in window")] + #[test_case(840100 => true; "first block")] + #[test_case(840200 => true; "last block")] + fn mint_block_height_terms_are_validated(block_height: u64) -> bool { + let mut rune = DbRune::factory(); + rune.terms_height_start(Some(PgNumericU64(840100))); + rune.terms_height_end(Some(PgNumericU64(840200))); + let mut location = TransactionLocation::factory(); + location.block_height(block_height); + is_valid_mint(&rune, 0, &location) + } + + #[test_case(840000 => false; "early block")] + #[test_case(840500 => false; "late block")] + #[test_case(840150 => true; "block in window")] + #[test_case(840100 => true; "first block")] + #[test_case(840200 => true; "last block")] + fn mint_block_offset_terms_are_validated(block_height: u64) -> bool { + let mut rune = DbRune::factory(); + rune.terms_offset_start(Some(PgNumericU64(100))); + rune.terms_offset_end(Some(PgNumericU64(200))); + let mut location = TransactionLocation::factory(); + location.block_height(block_height); + is_valid_mint(&rune, 0, &location) + } + + #[test_case(0 => true; "first mint")] + #[test_case(49 => true; "last mint")] + #[test_case(50 => false; "out of range")] + fn mint_cap_is_validated(cap: u128) -> bool { + let mut rune = DbRune::factory(); + rune.terms_cap(Some(PgNumericU128(50))); + is_valid_mint(&rune, cap, &TransactionLocation::factory()) + } } diff --git a/src/db/cache/transaction_location.rs b/src/db/cache/transaction_location.rs new file mode 100644 index 0000000..020d408 --- /dev/null +++ b/src/db/cache/transaction_location.rs @@ -0,0 +1,52 @@ +use std::fmt; + +use bitcoin::Network; +use ordinals::RuneId; + +#[derive(Debug, Clone)] +pub struct TransactionLocation { + pub network: Network, + pub block_hash: String, + pub block_height: u64, + pub timestamp: u32, + pub tx_index: u32, + pub tx_id: String, +} + +impl TransactionLocation { + pub fn rune_id(&self) -> RuneId { + RuneId { + block: self.block_height, + tx: self.tx_index, + } + } +} + +impl fmt::Display for TransactionLocation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "tx: {} ({}) @{}", + self.tx_id, self.tx_index, self.block_height + ) + } +} + +#[cfg(test)] +impl TransactionLocation { + pub fn factory() -> Self { + TransactionLocation { + network: Network::Bitcoin, + block_hash: "0000000000000000000320283a032748cef8227873ff4872689bf23f1cda83a5".to_string(), + block_height: 840000, + timestamp: 1713571767, + tx_index: 0, + tx_id: "2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e".to_string(), + } + } + + pub fn block_height(&mut self, val: u64) -> &Self { + self.block_height = val; + self + } +} diff --git a/src/db/models/db_rune.rs b/src/db/models/db_rune.rs index bba0428..fefa953 100644 --- a/src/db/models/db_rune.rs +++ b/src/db/models/db_rune.rs @@ -2,7 +2,7 @@ use ordinals::{Etching, Rune, RuneId, SpacedRune}; use tokio_postgres::Row; use crate::db::{ - cache::transaction_cache::TransactionLocation, + cache::transaction_location::TransactionLocation, types::{ pg_bigint_u32::PgBigIntU32, pg_numeric_u128::PgNumericU128, pg_numeric_u64::PgNumericU64, pg_smallint_u8::PgSmallIntU8, @@ -167,13 +167,70 @@ impl DbRune { } } +#[cfg(test)] +impl DbRune { + pub fn factory() -> Self { + DbRune { + id: "840000:1".to_string(), + number: PgBigIntU32(1), + name: "ZZZZZFEHUZZZZZ".to_string(), + spaced_name: "Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z".to_string(), + block_hash: "0000000000000000000320283a032748cef8227873ff4872689bf23f1cda83a5".to_string(), + block_height: PgNumericU64(840000), + tx_index: PgBigIntU32(1), + tx_id: "2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e".to_string(), + divisibility: PgSmallIntU8(2), + premine: PgNumericU128(11000000000), + symbol: "ᚠ".to_string(), + terms_amount: Some(PgNumericU128(100)), + terms_cap: Some(PgNumericU128(1111111)), + terms_height_start: None, + terms_height_end: None, + terms_offset_start: None, + terms_offset_end: None, + turbo: true, + minted: PgNumericU128(0), + total_mints: PgNumericU128(0), + burned: PgNumericU128(0), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(0), + timestamp: PgBigIntU32(1713571767), + } + } + + pub fn terms_height_start(&mut self, val: Option) -> &Self { + self.terms_height_start = val; + self + } + + pub fn terms_height_end(&mut self, val: Option) -> &Self { + self.terms_height_end = val; + self + } + + pub fn terms_offset_start(&mut self, val: Option) -> &Self { + self.terms_offset_start = val; + self + } + + pub fn terms_offset_end(&mut self, val: Option) -> &Self { + self.terms_offset_end = val; + self + } + + pub fn terms_cap(&mut self, val: Option) -> &Self { + self.terms_cap = val; + self + } +} + #[cfg(test)] mod test { use std::str::FromStr; use ordinals::{Etching, SpacedRune, Terms}; - use crate::db::cache::transaction_cache::TransactionLocation; + use crate::db::cache::transaction_location::TransactionLocation; use super::DbRune; From 3c563da9d18b9c989b8be079fe872211ed219709 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 19:14:18 -0600 Subject: [PATCH 08/14] feat: blocks and transactions endpoints --- api/src/api/init.ts | 6 +- .../api/routes/{address.ts => addresses.ts} | 14 +- api/src/api/routes/blocks.ts | 51 ++++++++ api/src/api/routes/etchings.ts | 48 ++++--- api/src/api/routes/transactions.ts | 51 ++++++++ api/src/api/schemas.ts | 52 ++++++-- api/src/api/util/helpers.ts | 4 +- api/src/pg/pg-store.ts | 123 +++++++++++------- 8 files changed, 254 insertions(+), 95 deletions(-) rename api/src/api/routes/{address.ts => addresses.ts} (86%) create mode 100644 api/src/api/routes/blocks.ts create mode 100644 api/src/api/routes/transactions.ts diff --git a/api/src/api/init.ts b/api/src/api/init.ts index 3c9d182..206b383 100644 --- a/api/src/api/init.ts +++ b/api/src/api/init.ts @@ -4,7 +4,9 @@ import { FastifyPluginAsync } from 'fastify'; import { Server } from 'http'; import { PgStore } from '../pg/pg-store'; import { EtchingRoutes } from './routes/etchings'; -import { AddressRoutes } from './routes/address'; +import { AddressRoutes } from './routes/addresses'; +import { TransactionRoutes } from './routes/transactions'; +import { BlockRoutes } from './routes/blocks'; export const Api: FastifyPluginAsync< Record, @@ -13,6 +15,8 @@ export const Api: FastifyPluginAsync< > = async fastify => { await fastify.register(EtchingRoutes); await fastify.register(AddressRoutes); + await fastify.register(TransactionRoutes); + await fastify.register(BlockRoutes); }; export async function buildApiServer(args: { db: PgStore }) { diff --git a/api/src/api/routes/address.ts b/api/src/api/routes/addresses.ts similarity index 86% rename from api/src/api/routes/address.ts rename to api/src/api/routes/addresses.ts index 6683ca6..448550b 100644 --- a/api/src/api/routes/address.ts +++ b/api/src/api/routes/addresses.ts @@ -3,9 +3,9 @@ import { Type } from '@sinclair/typebox'; import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; import { - AddressParamSchema, - LimitParamSchema, - OffsetParamSchema, + AddressSchema, + LimitSchema, + OffsetSchema, BalanceResponseSchema, } from '../schemas'; import { parseBalanceResponse } from '../util/helpers'; @@ -20,7 +20,7 @@ export const AddressRoutes: FastifyPluginCallback< fastify.addHook('preHandler', handleCache); fastify.get( - '/address/:address/balances', + '/addresses/:address/balances', { schema: { operationId: 'getAddressBalances', @@ -28,11 +28,11 @@ export const AddressRoutes: FastifyPluginCallback< description: 'Retrieves a paginated list of address balances', tags: ['Runes'], params: Type.Object({ - address: AddressParamSchema, + address: AddressSchema, }), querystring: Type.Object({ - offset: Optional(OffsetParamSchema), - limit: Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { 200: PaginatedResponse(BalanceResponseSchema, 'Paginated balances response'), diff --git a/api/src/api/routes/blocks.ts b/api/src/api/routes/blocks.ts new file mode 100644 index 0000000..a2d1751 --- /dev/null +++ b/api/src/api/routes/blocks.ts @@ -0,0 +1,51 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { LimitSchema, OffsetSchema, ActivityResponseSchema, BlockSchema } from '../schemas'; +import { parseActivityResponse } from '../util/helpers'; +import { Optional, PaginatedResponse } from '@hirosystems/api-toolkit'; +import { handleCache } from '../util/cache'; + +export const BlockRoutes: FastifyPluginCallback< + Record, + Server, + TypeBoxTypeProvider +> = (fastify, options, done) => { + fastify.addHook('preHandler', handleCache); + + fastify.get( + '/blocks/:block/activity', + { + schema: { + operationId: 'getBlockActivity', + summary: 'Get block activity', + description: 'Retrieves a paginated list of rune activity for a block', + tags: ['Runes'], + params: Type.Object({ + block: BlockSchema, + }), + querystring: Type.Object({ + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), + }), + response: { + 200: PaginatedResponse(ActivityResponseSchema, 'Paginated activity response'), + }, + }, + }, + async (request, reply) => { + const offset = request.query.offset ?? 0; + const limit = request.query.limit ?? 20; + const results = await fastify.db.getBlockActivity(request.params.block, offset, limit); + await reply.send({ + limit, + offset, + total: results.total, + results: results.results.map(r => parseActivityResponse(r)), + }); + } + ); + + done(); +}; diff --git a/api/src/api/routes/etchings.ts b/api/src/api/routes/etchings.ts index 36bfad8..3a2a673 100644 --- a/api/src/api/routes/etchings.ts +++ b/api/src/api/routes/etchings.ts @@ -4,20 +4,16 @@ import { Value } from '@sinclair/typebox/value'; import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; import { - AddressParamSchema, - EtchingParamSchema, + AddressSchema, + RuneSchema, EtchingResponseSchema, - LimitParamSchema, + LimitSchema, NotFoundResponse, - OffsetParamSchema, + OffsetSchema, SimpleBalanceResponseSchema, SimpleActivityResponseSchema, } from '../schemas'; -import { - parseBalanceResponse, - parseEtchingActivityResponse, - parseEtchingResponse, -} from '../util/helpers'; +import { parseBalanceResponse, parseActivityResponse, parseEtchingResponse } from '../util/helpers'; import { Optional, PaginatedResponse } from '@hirosystems/api-toolkit'; import { handleCache } from '../util/cache'; @@ -37,8 +33,8 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves a paginated list of rune etchings', tags: ['Runes'], querystring: Type.Object({ - offset: Optional(OffsetParamSchema), - limit: Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { 200: PaginatedResponse(EtchingResponseSchema, 'Paginated etchings response'), @@ -67,7 +63,7 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves information for a Rune etching', tags: ['Runes'], params: Type.Object({ - etching: EtchingParamSchema, + etching: RuneSchema, }), response: { 200: EtchingResponseSchema, @@ -94,11 +90,11 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves all activity for a Rune', tags: ['Runes'], params: Type.Object({ - etching: EtchingParamSchema, + etching: RuneSchema, }), querystring: Type.Object({ - offset: Optional(OffsetParamSchema), - limit: Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { 200: PaginatedResponse(SimpleActivityResponseSchema, 'Paginated activity response'), @@ -113,7 +109,7 @@ export const EtchingRoutes: FastifyPluginCallback< limit, offset, total: results.total, - results: results.results.map(r => parseEtchingActivityResponse(r)), + results: results.results.map(r => parseActivityResponse(r)), }); } ); @@ -127,12 +123,12 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves all activity for a Rune address', tags: ['Runes'], params: Type.Object({ - etching: EtchingParamSchema, - address: AddressParamSchema, + etching: RuneSchema, + address: AddressSchema, }), querystring: Type.Object({ - offset: Optional(OffsetParamSchema), - limit: Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { 200: PaginatedResponse(SimpleActivityResponseSchema, 'Paginated activity response'), @@ -152,7 +148,7 @@ export const EtchingRoutes: FastifyPluginCallback< limit, offset, total: results.total, - results: results.results.map(r => parseEtchingActivityResponse(r)), + results: results.results.map(r => parseActivityResponse(r)), }); } ); @@ -166,11 +162,11 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves a paginated list of holders for a Rune', tags: ['Runes'], params: Type.Object({ - etching: EtchingParamSchema, + etching: RuneSchema, }), querystring: Type.Object({ - offset: Optional(OffsetParamSchema), - limit: Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { 200: PaginatedResponse(SimpleBalanceResponseSchema, 'Paginated holders response'), @@ -199,8 +195,8 @@ export const EtchingRoutes: FastifyPluginCallback< description: 'Retrieves holder balance for a specific Rune', tags: ['Runes'], params: Type.Object({ - etching: EtchingParamSchema, - address: AddressParamSchema, + etching: RuneSchema, + address: AddressSchema, }), response: { 404: NotFoundResponse, diff --git a/api/src/api/routes/transactions.ts b/api/src/api/routes/transactions.ts new file mode 100644 index 0000000..da78387 --- /dev/null +++ b/api/src/api/routes/transactions.ts @@ -0,0 +1,51 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { Type } from '@sinclair/typebox'; +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { LimitSchema, OffsetSchema, ActivityResponseSchema, TransactionIdSchema } from '../schemas'; +import { parseActivityResponse } from '../util/helpers'; +import { Optional, PaginatedResponse } from '@hirosystems/api-toolkit'; +import { handleCache } from '../util/cache'; + +export const TransactionRoutes: FastifyPluginCallback< + Record, + Server, + TypeBoxTypeProvider +> = (fastify, options, done) => { + fastify.addHook('preHandler', handleCache); + + fastify.get( + '/transactions/:tx_id/activity', + { + schema: { + operationId: 'getTransactionActivity', + summary: 'Get transaction activity', + description: 'Retrieves a paginated list of rune activity for a transaction', + tags: ['Runes'], + params: Type.Object({ + tx_id: TransactionIdSchema, + }), + querystring: Type.Object({ + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), + }), + response: { + 200: PaginatedResponse(ActivityResponseSchema, 'Paginated activity response'), + }, + }, + }, + async (request, reply) => { + const offset = request.query.offset ?? 0; + const limit = request.query.limit ?? 20; + const results = await fastify.db.getTransactionActivity(request.params.tx_id, offset, limit); + await reply.send({ + limit, + offset, + total: results.total, + results: results.results.map(r => parseActivityResponse(r)), + }); + } + ); + + done(); +}; diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index 0e984b0..99e95cd 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -33,20 +33,20 @@ export const OpenApiSchemaOptions: SwaggerOptions = { // Parameters // ========================== -export const OffsetParamSchema = Type.Integer({ +export const OffsetSchema = Type.Integer({ minimum: 0, title: 'Offset', description: 'Result offset', }); -export type OffsetParam = Static; +export type Offset = Static; -export const LimitParamSchema = Type.Integer({ +export const LimitSchema = Type.Integer({ minimum: 1, maximum: 60, title: 'Limit', description: 'Results per page', }); -export type LimitParam = Static; +export type Limit = Static; const RuneIdSchema = Type.RegEx(/^[0-9]+:[0-9]+$/); const RuneNameSchema = Type.RegEx(/^[A-Z]+$/); @@ -54,11 +54,47 @@ export const RuneNameSchemaCType = TypeCompiler.Compile(RuneNameSchema); const RuneSpacedNameSchema = Type.RegEx(/^[A-Z](•[A-Z]+)+$/); export const RuneSpacedNameSchemaCType = TypeCompiler.Compile(RuneSpacedNameSchema); -export const EtchingParamSchema = Type.Union([RuneIdSchema, RuneNameSchema, RuneSpacedNameSchema]); -export type EtchingParam = Static; +export const RuneSchema = Type.Union([RuneIdSchema, RuneNameSchema, RuneSpacedNameSchema]); +export type Rune = Static; -export const AddressParamSchema = Type.String(); -export type AddressParam = Static; +export const AddressSchema = Type.String({ + title: 'Address', + description: 'Bitcoin address', + examples: ['bc1p8aq8s3z9xl87e74twfk93mljxq6alv4a79yheadx33t9np4g2wkqqt8kc5'], +}); +export type Address = Static; + +export const TransactionIdSchema = Type.RegEx(/^[a-fA-F0-9]{64}$/, { + title: 'Transaction ID', + description: 'A transaction ID', + examples: ['8f46f0d4ef685e650727e6faf7e30f23b851a7709714ec774f7909b3fb5e604c'], +}); +export type TransactionId = Static; + +export const TransactionOutputSchema = Type.RegEx(/^[a-fA-F0-9]{64}:[0-9]+$/, { + title: 'Transaction Output', + description: 'A transaction output', + examples: ['8f46f0d4ef685e650727e6faf7e30f23b851a7709714ec774f7909b3fb5e604c:0'], +}); +export type TransactionOutput = Static; + +export const BlockHeightSchema = Type.RegEx(/^[0-9]+$/, { + title: 'Block Height', + description: 'Bitcoin block height', + examples: [777678], +}); +export const BlockHeightCType = TypeCompiler.Compile(BlockHeightSchema); +export type BlockHeight = Static; + +const BlockHashSchema = Type.RegEx(/^[0]{8}[a-fA-F0-9]{56}$/, { + title: 'Block Hash', + description: 'Bitcoin block hash', + examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'], +}); +export type BlockHash = Static; + +export const BlockSchema = Type.Union([BlockHeightSchema, BlockHashSchema]); +export type Block = Static; // ========================== // Responses diff --git a/api/src/api/util/helpers.ts b/api/src/api/util/helpers.ts index 257520d..5201ba4 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -36,9 +36,7 @@ export function parseEtchingResponse(rune: DbRune): EtchingResponse { }; } -export function parseEtchingActivityResponse( - entry: DbItemWithRune -): ActivityResponse { +export function parseActivityResponse(entry: DbItemWithRune): ActivityResponse { return { rune: { id: entry.rune_id, diff --git a/api/src/pg/pg-store.ts b/api/src/pg/pg-store.ts index 8bd1255..7d92f03 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -15,23 +15,35 @@ import { DbRune, } from './types'; import { - AddressParam, - EtchingParam, - LimitParam, - OffsetParam, + Address, + BlockHeightCType, + Block, + Rune, + Limit, + Offset, RuneNameSchemaCType, RuneSpacedNameSchemaCType, + TransactionId, } from '../api/schemas'; -function getEtchingIdWhereCondition(sql: PgSqlClient, id: string, prefix?: string): PgSqlQuery { +function runeFilter(sql: PgSqlClient, etching: string, prefix?: string): PgSqlQuery { const p = prefix ? `${prefix}.` : ''; - let idParam = sql`${sql(`${p}id`)} = ${id}`; - if (RuneNameSchemaCType.Check(id)) { - idParam = sql`${sql(`${p}name`)} = ${id}`; - } else if (RuneSpacedNameSchemaCType.Check(id)) { - idParam = sql`${sql(`${p}spaced_name`)} = ${id}`; + let filter = sql`${sql(`${p}id`)} = ${etching}`; + if (RuneNameSchemaCType.Check(etching)) { + filter = sql`${sql(`${p}name`)} = ${etching}`; + } else if (RuneSpacedNameSchemaCType.Check(etching)) { + filter = sql`${sql(`${p}spaced_name`)} = ${etching}`; } - return idParam; + return filter; +} + +function blockFilter(sql: PgSqlClient, block: string, prefix?: string): PgSqlQuery { + const p = prefix ? `${prefix}.` : ''; + let filter = sql`${sql(`${p}block_hash`)} = ${block}`; + if (BlockHeightCType.Check(block)) { + filter = sql`${sql(`${p}block_height`)} = ${block}`; + } + return filter; } export class PgStore extends BasePgStore { @@ -67,15 +79,15 @@ export class PgStore extends BasePgStore { return result[0]?.etag; } - async getEtching(id: EtchingParam): Promise { + async getEtching(id: Rune): Promise { const result = await this.sql` - SELECT * FROM runes WHERE ${getEtchingIdWhereCondition(this.sql, id)} + SELECT * FROM runes WHERE ${runeFilter(this.sql, id)} `; if (result.count == 0) return undefined; return result[0]; } - async getEtchings(offset: OffsetParam, limit: LimitParam): Promise> { + async getEtchings(offset: Offset, limit: Limit): Promise> { const results = await this.sql[]>` WITH rune_count AS (SELECT COALESCE(MAX(number), 0) + 1 AS total FROM runes) SELECT *, (SELECT total FROM rune_count) @@ -89,17 +101,18 @@ export class PgStore extends BasePgStore { }; } - async getRuneActivity( - id: EtchingParam, - offset: OffsetParam, - limit: LimitParam + private async getActivity( + filter: PgSqlQuery, + count: PgSqlQuery, + offset: Offset, + limit: Limit ): Promise>> { const results = await this.sql>[]>` - SELECT l.*, r.name, r.spaced_name, r.divisibility, r.total_operations AS total + SELECT l.*, r.name, r.spaced_name, r.divisibility, ${count} AS total FROM ledger AS l INNER JOIN runes AS r ON r.id = l.rune_id - WHERE ${getEtchingIdWhereCondition(this.sql, id, 'r')} - ORDER BY l.block_height DESC, l.tx_index DESC + WHERE ${filter} + ORDER BY l.block_height DESC, l.tx_index DESC, l.event_index DESC OFFSET ${offset} LIMIT ${limit} `; return { @@ -108,37 +121,47 @@ export class PgStore extends BasePgStore { }; } - async getRuneAddressActivity( - id: EtchingParam, - address: AddressParam, - offset: OffsetParam, - limit: LimitParam - ): Promise>> { - const results = await this.sql>[]>` - SELECT l.*, r.name, r.spaced_name, r.divisibility, COUNT(*) OVER() AS total - FROM ledger AS l - INNER JOIN runes AS r ON r.id = l.rune_id - WHERE ${getEtchingIdWhereCondition(this.sql, id, 'r')} - AND address = ${address} - ORDER BY l.block_height DESC, l.tx_index DESC - OFFSET ${offset} LIMIT ${limit} - `; - return { - total: results[0]?.total ?? 0, - results, - }; + async getRuneActivity(runeId: Rune, offset: Offset, limit: Limit) { + return this.getActivity( + runeFilter(this.sql, runeId, 'r'), + this.sql`r.total_operations`, + offset, + limit + ); + } + + async getRuneAddressActivity(runeId: Rune, address: Address, offset: Offset, limit: Limit) { + return this.getActivity( + this.sql`${runeFilter(this.sql, runeId, 'r')} AND address = ${address}`, + this.sql`COUNT(*) OVER()`, + offset, + limit + ); + } + + async getTransactionActivity(txId: TransactionId, offset: Offset, limit: Limit) { + return this.getActivity(this.sql`l.tx_id = ${txId}`, this.sql`COUNT(*) OVER()`, offset, limit); + } + + async getBlockActivity(block: Block, offset: Offset, limit: Limit) { + return this.getActivity( + blockFilter(this.sql, block, 'l'), + this.sql`COUNT(*) OVER()`, + offset, + limit + ); } async getRuneHolders( - id: EtchingParam, - offset: OffsetParam, - limit: LimitParam + id: Rune, + offset: Offset, + limit: Limit ): Promise>> { const results = await this.sql>[]>` SELECT b.*, r.name, r.spaced_name, r.divisibility, COUNT(*) OVER() AS total FROM balances AS b INNER JOIN runes AS r ON r.id = b.rune_id - WHERE ${getEtchingIdWhereCondition(this.sql, id, 'r')} + WHERE ${runeFilter(this.sql, id, 'r')} ORDER BY b.balance DESC OFFSET ${offset} LIMIT ${limit} `; @@ -149,22 +172,22 @@ export class PgStore extends BasePgStore { } async getRuneAddressBalance( - id: EtchingParam, - address: AddressParam + id: Rune, + address: Address ): Promise | undefined> { const results = await this.sql[]>` SELECT b.*, r.name, r.spaced_name, r.divisibility FROM balances AS b INNER JOIN runes AS r ON r.id = b.rune_id - WHERE ${getEtchingIdWhereCondition(this.sql, id, 'r')} AND address = ${address} + WHERE ${runeFilter(this.sql, id, 'r')} AND address = ${address} `; return results[0]; } async getAddressBalances( - address: AddressParam, - offset: OffsetParam, - limit: LimitParam + address: Address, + offset: Offset, + limit: Limit ): Promise>> { const results = await this.sql>[]>` SELECT b.*, r.name, r.spaced_name, r.divisibility, COUNT(*) OVER() AS total From 59152b0df30b43bf213ab6df31233fbd5323dcb4 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 20:20:35 -0600 Subject: [PATCH 09/14] feat: rune supply --- api/src/api/schemas.ts | 16 ++++++++++------ api/src/api/util/helpers.ts | 28 ++++++++++++++++++++++------ api/src/pg/pg-store.ts | 18 +++++++++++------- api/src/pg/types.ts | 8 +++++--- 4 files changed, 48 insertions(+), 22 deletions(-) diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index 99e95cd..ab2f458 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -116,6 +116,8 @@ export const EtchingResponseSchema = Type.Object({ divisibility: Type.Integer({ examples: [2] }), premine: Type.String({ examples: ['11000000000'] }), symbol: Type.String({ examples: ['ᚠ'] }), + turbo: Type.Boolean({ examples: [false] }), + timestamp: Type.Integer({ examples: [1713571767] }), mint_terms: Type.Object({ amount: Nullable(Type.String({ examples: ['100'] })), cap: Nullable(Type.String({ examples: ['1111111'] })), @@ -124,12 +126,14 @@ export const EtchingResponseSchema = Type.Object({ offset_start: Nullable(Type.Integer({ examples: [0] })), offset_end: Nullable(Type.Integer({ examples: [200] })), }), - turbo: Type.Boolean({ examples: [false] }), - minted: Type.String({ examples: ['274916100'] }), - total_mints: Type.Integer({ examples: [250] }), - burned: Type.String({ examples: ['5100'] }), - total_burns: Type.Integer({ examples: [17] }), - timestamp: Type.Integer({ examples: [1713571767] }), + supply: Type.Object({ + minted: Type.String({ examples: ['274916100'] }), + total_mints: Type.String({ examples: ['250'] }), + mint_percentage: Type.String({ examples: ['59.4567'] }), + mintable: Type.Boolean(), + burned: Type.String({ examples: ['5100'] }), + total_burns: Type.String({ examples: ['17'] }), + }), }); export type EtchingResponse = Static; diff --git a/api/src/api/util/helpers.ts b/api/src/api/util/helpers.ts index 5201ba4..86e59a6 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -1,12 +1,22 @@ import BigNumber from 'bignumber.js'; -import { DbBalance, DbItemWithRune, DbLedgerEntry, DbRune } from '../../pg/types'; +import { DbBalance, DbItemWithRune, DbLedgerEntry, DbRuneWithChainTip } from '../../pg/types'; import { EtchingResponse, ActivityResponse, BalanceResponse } from '../schemas'; function divisibility(num: string, decimals: number): string { return new BigNumber(num).shiftedBy(-1 * decimals).toFixed(decimals); } -export function parseEtchingResponse(rune: DbRune): EtchingResponse { +export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse { + let mintable = true; + if ( + (rune.terms_cap && BigNumber(rune.total_mints).gte(rune.terms_cap)) || + (rune.terms_height_start && rune.chain_tip < rune.terms_height_start) || + (rune.terms_height_end && rune.chain_tip > rune.terms_height_end) || + (rune.terms_offset_start && rune.chain_tip < rune.block_height + rune.terms_offset_start) || + (rune.terms_offset_end && rune.chain_tip > rune.block_height + rune.terms_offset_end) + ) { + mintable = false; + } return { id: rune.id, number: rune.number, @@ -27,11 +37,17 @@ export function parseEtchingResponse(rune: DbRune): EtchingResponse { offset_start: rune.terms_offset_start, offset_end: rune.terms_offset_end, }, + supply: { + minted: divisibility(rune.minted, rune.divisibility), + total_mints: rune.total_mints, + burned: divisibility(rune.burned, rune.divisibility), + total_burns: rune.total_burns, + mint_percentage: rune.terms_cap + ? BigNumber(rune.total_mints).div(rune.terms_cap).times(100).toFixed(4) + : '0.0000', + mintable, + }, turbo: rune.turbo, - minted: divisibility(rune.minted, rune.divisibility), - total_mints: rune.total_mints, - burned: divisibility(rune.burned, rune.divisibility), - total_burns: rune.total_burns, timestamp: rune.timestamp, }; } diff --git a/api/src/pg/pg-store.ts b/api/src/pg/pg-store.ts index 7d92f03..78ed8dd 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -13,6 +13,7 @@ import { DbLedgerEntry, DbPaginatedResult, DbRune, + DbRuneWithChainTip, } from './types'; import { Address, @@ -79,18 +80,21 @@ export class PgStore extends BasePgStore { return result[0]?.etag; } - async getEtching(id: Rune): Promise { - const result = await this.sql` - SELECT * FROM runes WHERE ${runeFilter(this.sql, id)} + async getEtching(id: Rune): Promise { + const result = await this.sql` + SELECT *, (SELECT MAX(block_height) FROM ledger) AS chain_tip + FROM runes WHERE ${runeFilter(this.sql, id)} `; if (result.count == 0) return undefined; return result[0]; } - async getEtchings(offset: Offset, limit: Limit): Promise> { - const results = await this.sql[]>` - WITH rune_count AS (SELECT COALESCE(MAX(number), 0) + 1 AS total FROM runes) - SELECT *, (SELECT total FROM rune_count) + async getEtchings(offset: Offset, limit: Limit): Promise> { + const results = await this.sql[]>` + WITH + rune_count AS (SELECT COALESCE(MAX(number), 0) + 1 AS total FROM runes), + max AS (SELECT MAX(block_height) AS max FROM ledger) + SELECT *, (SELECT total FROM rune_count), (SELECT chain_tip FROM max) FROM runes ORDER BY block_height DESC, tx_index DESC OFFSET ${offset} LIMIT ${limit} diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index 299667c..f04e9ec 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -25,13 +25,15 @@ export type DbRune = { terms_offset_end: number | null; turbo: boolean; minted: string; - total_mints: number; + total_mints: string; burned: string; - total_burns: number; - total_operations: number; + total_burns: string; + total_operations: string; timestamp: number; }; +export type DbRuneWithChainTip = DbRune & { chain_tip: number }; + type DbLedgerOperation = 'mint' | 'burn' | 'send' | 'receive'; export type DbLedgerEntry = { From 99038e5b063264a4cb1f0191f1b441e17683be2a Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 20:52:37 -0600 Subject: [PATCH 10/14] fix: mintable, field types, get rune by number --- api/src/api/schemas.ts | 10 +++++++++- api/src/api/util/helpers.ts | 29 ++++++++++++++++++----------- api/src/pg/pg-store.ts | 4 +++- api/src/pg/types.ts | 14 +++++++------- src/db/cache/transaction_cache.rs | 9 +++++---- 5 files changed, 42 insertions(+), 24 deletions(-) diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index ab2f458..e7d531b 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -49,12 +49,19 @@ export const LimitSchema = Type.Integer({ export type Limit = Static; const RuneIdSchema = Type.RegEx(/^[0-9]+:[0-9]+$/); +const RuneNumberSchema = Type.RegEx(/^[0-9]+$/); +export const RuneNumberSchemaCType = TypeCompiler.Compile(RuneNumberSchema); const RuneNameSchema = Type.RegEx(/^[A-Z]+$/); export const RuneNameSchemaCType = TypeCompiler.Compile(RuneNameSchema); const RuneSpacedNameSchema = Type.RegEx(/^[A-Z](•[A-Z]+)+$/); export const RuneSpacedNameSchemaCType = TypeCompiler.Compile(RuneSpacedNameSchema); -export const RuneSchema = Type.Union([RuneIdSchema, RuneNameSchema, RuneSpacedNameSchema]); +export const RuneSchema = Type.Union([ + RuneIdSchema, + RuneNumberSchema, + RuneNameSchema, + RuneSpacedNameSchema, +]); export type Rune = Static; export const AddressSchema = Type.String({ @@ -127,6 +134,7 @@ export const EtchingResponseSchema = Type.Object({ offset_end: Nullable(Type.Integer({ examples: [200] })), }), supply: Type.Object({ + current: Type.String({ examples: ['11274916350'] }), minted: Type.String({ examples: ['274916100'] }), total_mints: Type.String({ examples: ['250'] }), mint_percentage: Type.String({ examples: ['59.4567'] }), diff --git a/api/src/api/util/helpers.ts b/api/src/api/util/helpers.ts index 86e59a6..1a4c478 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -2,18 +2,21 @@ import BigNumber from 'bignumber.js'; import { DbBalance, DbItemWithRune, DbLedgerEntry, DbRuneWithChainTip } from '../../pg/types'; import { EtchingResponse, ActivityResponse, BalanceResponse } from '../schemas'; -function divisibility(num: string, decimals: number): string { +function divisibility(num: string | BigNumber, decimals: number): string { return new BigNumber(num).shiftedBy(-1 * decimals).toFixed(decimals); } export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse { let mintable = true; if ( + rune.terms_amount == null || (rune.terms_cap && BigNumber(rune.total_mints).gte(rune.terms_cap)) || - (rune.terms_height_start && rune.chain_tip < rune.terms_height_start) || - (rune.terms_height_end && rune.chain_tip > rune.terms_height_end) || - (rune.terms_offset_start && rune.chain_tip < rune.block_height + rune.terms_offset_start) || - (rune.terms_offset_end && rune.chain_tip > rune.block_height + rune.terms_offset_end) + (rune.terms_height_start && BigNumber(rune.chain_tip).lt(rune.terms_height_start)) || + (rune.terms_height_end && BigNumber(rune.chain_tip).gt(rune.terms_height_end)) || + (rune.terms_offset_start && + BigNumber(rune.chain_tip).lt(BigNumber(rune.block_height).plus(rune.terms_offset_start))) || + (rune.terms_offset_end && + BigNumber(rune.chain_tip).gt(BigNumber(rune.block_height).plus(rune.terms_offset_end))) ) { mintable = false; } @@ -23,7 +26,7 @@ export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse name: rune.name, spaced_name: rune.spaced_name, block_hash: rune.block_hash, - block_height: rune.block_height, + block_height: parseInt(rune.block_height), tx_index: rune.tx_index, tx_id: rune.tx_id, divisibility: rune.divisibility, @@ -32,12 +35,16 @@ export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse mint_terms: { amount: rune.terms_amount ? divisibility(rune.terms_amount, rune.divisibility) : null, cap: rune.terms_cap ? divisibility(rune.terms_cap, rune.divisibility) : null, - height_start: rune.terms_height_start, - height_end: rune.terms_height_end, - offset_start: rune.terms_offset_start, - offset_end: rune.terms_offset_end, + height_start: rune.terms_height_start ? parseInt(rune.terms_height_start) : null, + height_end: rune.terms_height_end ? parseInt(rune.terms_height_end) : null, + offset_start: rune.terms_offset_start ? parseInt(rune.terms_offset_start) : null, + offset_end: rune.terms_offset_end ? parseInt(rune.terms_offset_end) : null, }, supply: { + current: divisibility( + BigNumber(rune.minted).plus(rune.burned).plus(rune.premine), + rune.divisibility + ), minted: divisibility(rune.minted, rune.divisibility), total_mints: rune.total_mints, burned: divisibility(rune.burned, rune.divisibility), @@ -60,7 +67,7 @@ export function parseActivityResponse(entry: DbItemWithRune): Act spaced_name: entry.spaced_name, }, block_hash: entry.block_hash, - block_height: entry.block_height, + block_height: parseInt(entry.block_height), tx_index: entry.tx_index, tx_id: entry.tx_id, vout: entry.output, diff --git a/api/src/pg/pg-store.ts b/api/src/pg/pg-store.ts index 78ed8dd..0af8a40 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -12,7 +12,6 @@ import { DbItemWithRune, DbLedgerEntry, DbPaginatedResult, - DbRune, DbRuneWithChainTip, } from './types'; import { @@ -25,6 +24,7 @@ import { RuneNameSchemaCType, RuneSpacedNameSchemaCType, TransactionId, + RuneNumberSchemaCType, } from '../api/schemas'; function runeFilter(sql: PgSqlClient, etching: string, prefix?: string): PgSqlQuery { @@ -34,6 +34,8 @@ function runeFilter(sql: PgSqlClient, etching: string, prefix?: string): PgSqlQu filter = sql`${sql(`${p}name`)} = ${etching}`; } else if (RuneSpacedNameSchemaCType.Check(etching)) { filter = sql`${sql(`${p}spaced_name`)} = ${etching}`; + } else if (RuneNumberSchemaCType.Check(etching)) { + filter = sql`${sql(`${p}number`)} = ${etching}`; } return filter; } diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index f04e9ec..dc60ad3 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -11,7 +11,7 @@ export type DbRune = { name: string; spaced_name: string; block_hash: string; - block_height: number; + block_height: string; tx_index: number; tx_id: string; divisibility: number; @@ -19,10 +19,10 @@ export type DbRune = { symbol: string; terms_amount: string | null; terms_cap: string | null; - terms_height_start: number | null; - terms_height_end: number | null; - terms_offset_start: number | null; - terms_offset_end: number | null; + terms_height_start: string | null; + terms_height_end: string | null; + terms_offset_start: string | null; + terms_offset_end: string | null; turbo: boolean; minted: string; total_mints: string; @@ -32,14 +32,14 @@ export type DbRune = { timestamp: number; }; -export type DbRuneWithChainTip = DbRune & { chain_tip: number }; +export type DbRuneWithChainTip = DbRune & { chain_tip: string }; type DbLedgerOperation = 'mint' | 'burn' | 'send' | 'receive'; export type DbLedgerEntry = { rune_id: string; block_hash: string; - block_height: number; + block_height: string; tx_index: number; tx_id: string; output: number; diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index c4d5f14..9fc8fa7 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -210,8 +210,6 @@ impl TransactionCache { db_rune: &DbRune, ctx: &Context, ) -> Option { - // TODO: What's the default mint amount if none was provided? - let terms_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); if !is_valid_mint(db_rune, total_mints, &self.location) { debug!( ctx.expect_logger(), @@ -219,6 +217,7 @@ impl TransactionCache { ); return None; } + let terms_amount = db_rune.terms_amount.unwrap(); info!( ctx.expect_logger(), "MINT {} ({}) {} {}", rune_id, db_rune.spaced_name, terms_amount.0, self.location @@ -249,8 +248,6 @@ impl TransactionCache { db_rune: &DbRune, ctx: &Context, ) -> Option { - // TODO: What's the default mint amount if none was provided? - let terms_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); if !is_valid_mint(db_rune, total_mints, &self.location) { debug!( ctx.expect_logger(), @@ -258,6 +255,7 @@ impl TransactionCache { ); return None; } + let terms_amount = db_rune.terms_amount.unwrap(); info!( ctx.expect_logger(), "CENOTAPH MINT {} {} {}", db_rune.spaced_name, terms_amount.0, self.location @@ -423,6 +421,9 @@ impl TransactionCache { /// Determines if a mint is valid depending on the rune's mint terms. fn is_valid_mint(db_rune: &DbRune, total_mints: u128, location: &TransactionLocation) -> bool { + if db_rune.terms_amount.is_none() { + return false; + } if let Some(terms_cap) = db_rune.terms_cap { if total_mints >= terms_cap.0 { return false; From 67b61ee99c14abff81bb9cfaa90f83ef990f6810 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sat, 29 Jun 2024 22:45:27 -0600 Subject: [PATCH 11/14] feat: status endpoint --- api/src/api/init.ts | 2 ++ api/src/api/routes/status.ts | 42 ++++++++++++++++++++++++++++++++++++ api/src/api/schemas.ts | 9 ++++++++ api/src/pg/pg-store.ts | 9 +++++++- 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 api/src/api/routes/status.ts diff --git a/api/src/api/init.ts b/api/src/api/init.ts index 206b383..93a9065 100644 --- a/api/src/api/init.ts +++ b/api/src/api/init.ts @@ -7,12 +7,14 @@ import { EtchingRoutes } from './routes/etchings'; import { AddressRoutes } from './routes/addresses'; import { TransactionRoutes } from './routes/transactions'; import { BlockRoutes } from './routes/blocks'; +import { StatusRoutes } from './routes/status'; export const Api: FastifyPluginAsync< Record, Server, TypeBoxTypeProvider > = async fastify => { + await fastify.register(StatusRoutes); await fastify.register(EtchingRoutes); await fastify.register(AddressRoutes); await fastify.register(TransactionRoutes); diff --git a/api/src/api/routes/status.ts b/api/src/api/routes/status.ts new file mode 100644 index 0000000..8beccde --- /dev/null +++ b/api/src/api/routes/status.ts @@ -0,0 +1,42 @@ +import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { FastifyPluginCallback } from 'fastify'; +import { Server } from 'http'; +import { ApiStatusResponse } from '../schemas'; +import { SERVER_VERSION } from '@hirosystems/api-toolkit'; +import { handleCache } from '../util/cache'; + +export const StatusRoutes: FastifyPluginCallback< + Record, + Server, + TypeBoxTypeProvider +> = (fastify, options, done) => { + fastify.addHook('preHandler', handleCache); + + fastify.get( + '/', + { + schema: { + operationId: 'getApiStatus', + summary: 'API Status', + description: 'Displays the status of the API', + tags: ['Status'], + response: { + 200: ApiStatusResponse, + }, + }, + }, + async (request, reply) => { + const result = await fastify.db.sqlTransaction(async sql => { + const block_height = await fastify.db.getChainTipBlockHeight(); + return { + server_version: `runes-api ${SERVER_VERSION.tag} (${SERVER_VERSION.branch}:${SERVER_VERSION.commit})`, + status: 'ready', + block_height: block_height ? parseInt(block_height) : undefined, + }; + }); + await reply.send(result); + } + ); + + done(); +}; diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index e7d531b..c7d0a36 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -107,6 +107,15 @@ export type Block = Static; // Responses // ========================== +export const ApiStatusResponse = Type.Object( + { + server_version: Type.String({ examples: [''] }), + status: Type.String(), + block_height: Optional(Type.Integer()), + }, + { title: 'Api Status Response' } +); + export const EtchingResponseSchema = Type.Object({ id: Type.String({ examples: ['840000:1'] }), name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), diff --git a/api/src/pg/pg-store.ts b/api/src/pg/pg-store.ts index 0af8a40..a1d92bd 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -82,6 +82,13 @@ export class PgStore extends BasePgStore { return result[0]?.etag; } + async getChainTipBlockHeight(): Promise { + const result = await this.sql<{ block_height: string }[]>` + SELECT block_height FROM ledger ORDER BY block_height DESC LIMIT 1 + `; + return result[0]?.block_height; + } + async getEtching(id: Rune): Promise { const result = await this.sql` SELECT *, (SELECT MAX(block_height) FROM ledger) AS chain_tip @@ -95,7 +102,7 @@ export class PgStore extends BasePgStore { const results = await this.sql[]>` WITH rune_count AS (SELECT COALESCE(MAX(number), 0) + 1 AS total FROM runes), - max AS (SELECT MAX(block_height) AS max FROM ledger) + max AS (SELECT MAX(block_height) AS chain_tip FROM ledger) SELECT *, (SELECT total FROM rune_count), (SELECT chain_tip FROM max) FROM runes ORDER BY block_height DESC, tx_index DESC From 8e1016a90262fad31e2ee213dfa67a3d0986743f Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sun, 30 Jun 2024 14:15:24 -0600 Subject: [PATCH 12/14] fix: location api object --- api/src/api/schemas.ts | 47 ++++++++++++------------- api/src/api/util/helpers.ts | 32 +++++++++-------- api/src/pg/types.ts | 6 ++-- migrations/V2__ledger.sql | 4 +-- src/db/cache/index_cache.rs | 36 ++++++++++++------- src/db/cache/transaction_cache.rs | 52 +++++++++++++++++++++------- src/db/models/db_ledger_entry.rs | 6 ++-- src/db/models/db_ledger_operation.rs | 3 ++ 8 files changed, 116 insertions(+), 70 deletions(-) diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index c7d0a36..eebcd81 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -1,6 +1,6 @@ import { SwaggerOptions } from '@fastify/swagger'; import { Nullable, Optional, SERVER_VERSION } from '@hirosystems/api-toolkit'; -import { Static, TSchema, Type } from '@sinclair/typebox'; +import { Static, Type } from '@sinclair/typebox'; import { TypeCompiler } from '@sinclair/typebox/compiler'; export const OpenApiSchemaOptions: SwaggerOptions = { @@ -116,24 +116,32 @@ export const ApiStatusResponse = Type.Object( { title: 'Api Status Response' } ); -export const EtchingResponseSchema = Type.Object({ - id: Type.String({ examples: ['840000:1'] }), - name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), - spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), - number: Type.Integer({ examples: [1] }), +const LocationDetailResponseSchema = Type.Object({ block_hash: Type.String({ examples: ['00000000000000000000c9787573a1f1775a2b56b403a2d0c7957e9a5bc754bb'], }), block_height: Type.Integer({ examples: [840000] }), - tx_index: Type.Integer({ examples: [1] }), tx_id: Type.String({ examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e'], }), + tx_index: Type.Integer({ examples: [1] }), + vout: Optional(Type.Integer({ examples: [100] })), + output: Optional( + Type.String({ + examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e:100'], + }) + ), + timestamp: Type.Integer({ examples: [1713571767] }), +}); + +export const EtchingResponseSchema = Type.Object({ + id: Type.String({ examples: ['840000:1'] }), + name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), + spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), + number: Type.Integer({ examples: [1] }), divisibility: Type.Integer({ examples: [2] }), - premine: Type.String({ examples: ['11000000000'] }), symbol: Type.String({ examples: ['ᚠ'] }), turbo: Type.Boolean({ examples: [false] }), - timestamp: Type.Integer({ examples: [1713571767] }), mint_terms: Type.Object({ amount: Nullable(Type.String({ examples: ['100'] })), cap: Nullable(Type.String({ examples: ['1111111'] })), @@ -150,7 +158,9 @@ export const EtchingResponseSchema = Type.Object({ mintable: Type.Boolean(), burned: Type.String({ examples: ['5100'] }), total_burns: Type.String({ examples: ['17'] }), + premine: Type.String({ examples: ['11000000000'] }), }), + location: LocationDetailResponseSchema, }); export type EtchingResponse = Static; @@ -163,30 +173,19 @@ const RuneDetailResponseSchema = Type.Object({ }); export const SimpleActivityResponseSchema = Type.Object({ - block_hash: Type.String({ - examples: ['00000000000000000000c9787573a1f1775a2b56b403a2d0c7957e9a5bc754bb'], - }), - block_height: Type.Integer({ examples: [840000] }), - tx_index: Type.Integer({ examples: [1] }), - tx_id: Type.String({ - examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e'], - }), - vout: Type.Integer({ examples: [100] }), - output: Type.String({ - examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e:100'], - }), address: Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), - receiver_address: Type.Optional( + receiver_address: Optional( Type.String({ examples: ['bc1pgdrveee2v4ez95szaakw5gkd8eennv2dddf9rjdrlt6ch56lzrrsxgvazt'] }) ), - amount: Type.String({ examples: ['11000000000'] }), + amount: Optional(Type.String({ examples: ['11000000000'] })), operation: Type.Union([ + Type.Literal('etching'), Type.Literal('mint'), Type.Literal('burn'), Type.Literal('send'), Type.Literal('receive'), ]), - timestamp: Type.Integer({ examples: [1713571767] }), + location: LocationDetailResponseSchema, }); export type SimpleActivityResponse = Static; diff --git a/api/src/api/util/helpers.ts b/api/src/api/util/helpers.ts index 1a4c478..28fe0de 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -25,12 +25,7 @@ export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse number: rune.number, name: rune.name, spaced_name: rune.spaced_name, - block_hash: rune.block_hash, - block_height: parseInt(rune.block_height), - tx_index: rune.tx_index, - tx_id: rune.tx_id, divisibility: rune.divisibility, - premine: divisibility(rune.premine, rune.divisibility), symbol: rune.symbol, mint_terms: { amount: rune.terms_amount ? divisibility(rune.terms_amount, rune.divisibility) : null, @@ -41,6 +36,7 @@ export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse offset_end: rune.terms_offset_end ? parseInt(rune.terms_offset_end) : null, }, supply: { + premine: divisibility(rune.premine, rune.divisibility), current: divisibility( BigNumber(rune.minted).plus(rune.burned).plus(rune.premine), rune.divisibility @@ -55,7 +51,13 @@ export function parseEtchingResponse(rune: DbRuneWithChainTip): EtchingResponse mintable, }, turbo: rune.turbo, - timestamp: rune.timestamp, + location: { + block_hash: rune.block_hash, + block_height: parseInt(rune.block_height), + tx_index: rune.tx_index, + tx_id: rune.tx_id, + timestamp: rune.timestamp, + }, }; } @@ -66,17 +68,19 @@ export function parseActivityResponse(entry: DbItemWithRune): Act name: entry.name, spaced_name: entry.spaced_name, }, - block_hash: entry.block_hash, - block_height: parseInt(entry.block_height), - tx_index: entry.tx_index, - tx_id: entry.tx_id, - vout: entry.output, - output: `${entry.tx_id}:${entry.output}`, operation: entry.operation, address: entry.address ?? undefined, receiver_address: entry.receiver_address ?? undefined, - timestamp: entry.timestamp, - amount: divisibility(entry.amount, entry.divisibility), + amount: entry.amount ? divisibility(entry.amount, entry.divisibility) : undefined, + location: { + block_hash: entry.block_hash, + block_height: parseInt(entry.block_height), + tx_index: entry.tx_index, + tx_id: entry.tx_id, + vout: entry.output ?? undefined, + output: entry.output ? `${entry.tx_id}:${entry.output}` : undefined, + timestamp: entry.timestamp, + }, }; } diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index dc60ad3..ccaffe8 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -34,7 +34,7 @@ export type DbRune = { export type DbRuneWithChainTip = DbRune & { chain_tip: string }; -type DbLedgerOperation = 'mint' | 'burn' | 'send' | 'receive'; +type DbLedgerOperation = 'etching' | 'mint' | 'burn' | 'send' | 'receive'; export type DbLedgerEntry = { rune_id: string; @@ -42,10 +42,10 @@ export type DbLedgerEntry = { block_height: string; tx_index: number; tx_id: string; - output: number; + output: number | null; address: string | null; receiver_address: string | null; - amount: string; + amount: string | null; operation: DbLedgerOperation; timestamp: number; }; diff --git a/migrations/V2__ledger.sql b/migrations/V2__ledger.sql index 8c94972..aaf4fdf 100644 --- a/migrations/V2__ledger.sql +++ b/migrations/V2__ledger.sql @@ -1,4 +1,4 @@ -CREATE TYPE ledger_operation AS ENUM ('mint', 'burn', 'send', 'receive'); +CREATE TYPE ledger_operation AS ENUM ('etching', 'mint', 'burn', 'send', 'receive'); CREATE TABLE IF NOT EXISTS ledger ( rune_id TEXT NOT NULL, @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS ledger ( output BIGINT, address TEXT, receiver_address TEXT, - amount NUMERIC NOT NULL, + amount NUMERIC, operation ledger_operation NOT NULL, timestamp BIGINT NOT NULL ); diff --git a/src/db/cache/index_cache.rs b/src/db/cache/index_cache.rs index a63f601..0d9ca25 100644 --- a/src/db/cache/index_cache.rs +++ b/src/db/cache/index_cache.rs @@ -122,13 +122,14 @@ impl IndexCache { _db_tx: &mut Transaction<'_>, ctx: &Context, ) { - let (rune_id, db_rune) = self.tx_cache.apply_etching(etching, self.next_rune_number); + let (rune_id, db_rune, entry) = self.tx_cache.apply_etching(etching, self.next_rune_number); info!( ctx.expect_logger(), "Etching {} ({}) {}", db_rune.spaced_name, db_rune.id, self.tx_cache.location ); self.db_cache.runes.push(db_rune.clone()); self.rune_cache.put(rune_id, db_rune); + self.add_ledger_entries_to_db_cache(&vec![entry]); self.next_rune_number += 1; } @@ -138,7 +139,7 @@ impl IndexCache { _db_tx: &mut Transaction<'_>, ctx: &Context, ) { - let (rune_id, db_rune) = self + let (rune_id, db_rune, entry) = self .tx_cache .apply_cenotaph_etching(rune, self.next_rune_number); info!( @@ -147,6 +148,7 @@ impl IndexCache { ); self.db_cache.runes.push(db_rune.clone()); self.rune_cache.put(rune_id, db_rune); + self.add_ledger_entries_to_db_cache(&vec![entry]); self.next_rune_number += 1; } @@ -222,7 +224,10 @@ impl IndexCache { for entry in entries.iter() { info!( ctx.expect_logger(), - "Edict {} {} {}", db_rune.spaced_name, entry.amount.0, self.tx_cache.location + "Edict {} {} {}", + db_rune.spaced_name, + entry.amount.unwrap().0, + self.tx_cache.location ); } self.add_ledger_entries_to_db_cache(&entries); @@ -329,27 +334,34 @@ impl IndexCache { self.db_cache.ledger_entries.extend(entries.clone()); for entry in entries.iter() { match entry.operation { + DbLedgerOperation::Etching => {} DbLedgerOperation::Mint => { self.db_cache .rune_updates .entry(entry.rune_id.clone()) .and_modify(|i| { - i.minted += entry.amount; + i.minted += entry.amount.unwrap(); i.total_mints += 1; i.total_operations += 1; }) - .or_insert(DbRuneUpdate::from_mint(entry.rune_id.clone(), entry.amount)); + .or_insert(DbRuneUpdate::from_mint( + entry.rune_id.clone(), + entry.amount.unwrap(), + )); } DbLedgerOperation::Burn => { self.db_cache .rune_updates .entry(entry.rune_id.clone()) .and_modify(|i| { - i.burned += entry.amount; + i.burned += entry.amount.unwrap(); i.total_burns += 1; i.total_operations += 1; }) - .or_insert(DbRuneUpdate::from_burn(entry.rune_id.clone(), entry.amount)); + .or_insert(DbRuneUpdate::from_burn( + entry.rune_id.clone(), + entry.amount.unwrap(), + )); } DbLedgerOperation::Send => { self.db_cache @@ -361,11 +373,11 @@ impl IndexCache { self.db_cache .balance_deductions .entry((entry.rune_id.clone(), address.clone())) - .and_modify(|i| i.balance += entry.amount) + .and_modify(|i| i.balance += entry.amount.unwrap()) .or_insert(DbBalanceUpdate::from_operation( entry.rune_id.clone(), address, - entry.amount, + entry.amount.unwrap(), )); } } @@ -379,11 +391,11 @@ impl IndexCache { self.db_cache .balance_increases .entry((entry.rune_id.clone(), address.clone())) - .and_modify(|i| i.balance += entry.amount) + .and_modify(|i| i.balance += entry.amount.unwrap()) .or_insert(DbBalanceUpdate::from_operation( entry.rune_id.clone(), address, - entry.amount, + entry.amount.unwrap(), )); } @@ -392,7 +404,7 @@ impl IndexCache { let rune_id = RuneId::from_str(entry.rune_id.as_str()).unwrap(); let balance = InputRuneBalance { address: entry.address.clone(), - amount: entry.amount.0, + amount: entry.amount.unwrap().0, }; if let Some(v) = self.output_cache.get_mut(&k) { if let Some(rune_balance) = v.get_mut(&rune_id) { diff --git a/src/db/cache/transaction_cache.rs b/src/db/cache/transaction_cache.rs index 9fc8fa7..033b86f 100644 --- a/src/db/cache/transaction_cache.rs +++ b/src/db/cache/transaction_cache.rs @@ -134,7 +134,7 @@ impl TransactionCache { for balance in unallocated { results.push(new_ledger_entry( &self.location, - balance.amount, + Some(balance.amount), *rune_id, None, balance.address.as_ref(), @@ -178,7 +178,11 @@ impl TransactionCache { results } - pub fn apply_etching(&mut self, etching: &Etching, number: u32) -> (RuneId, DbRune) { + pub fn apply_etching( + &mut self, + etching: &Etching, + number: u32, + ) -> (RuneId, DbRune, DbLedgerEntry) { let rune_id = self.location.rune_id(); let db_rune = DbRune::from_etching(etching, number, &self.location); self.etching = Some(db_rune.clone()); @@ -192,15 +196,39 @@ impl TransactionCache { }, ); } - (rune_id, db_rune) + let entry = new_ledger_entry( + &self.location, + None, + rune_id, + None, + None, + None, + DbLedgerOperation::Etching, + &mut self.next_event_index, + ); + (rune_id, db_rune, entry) } - pub fn apply_cenotaph_etching(&mut self, rune: &Rune, number: u32) -> (RuneId, DbRune) { + pub fn apply_cenotaph_etching( + &mut self, + rune: &Rune, + number: u32, + ) -> (RuneId, DbRune, DbLedgerEntry) { let rune_id = self.location.rune_id(); // If the runestone that produced the cenotaph contained an etching, the etched rune has supply zero and is unmintable. let db_rune = DbRune::from_cenotaph_etching(rune, number, &self.location); self.etching = Some(db_rune.clone()); - (rune_id, db_rune) + let entry = new_ledger_entry( + &self.location, + None, + rune_id, + None, + None, + None, + DbLedgerOperation::Etching, + &mut self.next_event_index, + ); + (rune_id, db_rune, entry) } pub fn apply_mint( @@ -231,7 +259,7 @@ impl TransactionCache { ); Some(new_ledger_entry( &self.location, - terms_amount.0, + Some(terms_amount.0), rune_id.clone(), None, None, @@ -263,7 +291,7 @@ impl TransactionCache { // This entry does not go in the input runes, it gets burned immediately. Some(new_ledger_entry( &self.location, - terms_amount.0, + Some(terms_amount.0), rune_id.clone(), None, None, @@ -455,7 +483,7 @@ fn is_valid_mint(db_rune: &DbRune, total_mints: u128, location: &TransactionLoca /// Creates a new ledger entry. fn new_ledger_entry( location: &TransactionLocation, - amount: u128, + amount: Option, rune_id: RuneId, output: Option, address: Option<&String>, @@ -559,7 +587,7 @@ fn move_rune_balance_to_output( if receiver_address.is_some() && total_sent > 0 { results.push(new_ledger_entry( location, - total_sent, + Some(total_sent), *rune_id, output, receiver_address.as_ref(), @@ -581,7 +609,7 @@ fn move_rune_balance_to_output( for (balance_taken, sender_address) in senders.iter() { results.push(new_ledger_entry( location, - *balance_taken, + Some(*balance_taken), *rune_id, output, Some(sender_address), @@ -673,12 +701,12 @@ mod test { let receive = results.get(0).unwrap(); assert_eq!(receive.event_index.0, 0u32); assert_eq!(receive.operation, DbLedgerOperation::Receive); - assert_eq!(receive.amount.0, 2000u128); + assert_eq!(receive.amount.unwrap().0, 2000u128); let send = results.get(1).unwrap(); assert_eq!(send.event_index.0, 1u32); assert_eq!(send.operation, DbLedgerOperation::Send); - assert_eq!(send.amount.0, 1000u128); + assert_eq!(send.amount.unwrap().0, 1000u128); assert_eq!(results.len(), 2); } diff --git a/src/db/models/db_ledger_entry.rs b/src/db/models/db_ledger_entry.rs index 917b8d7..ec171bb 100644 --- a/src/db/models/db_ledger_entry.rs +++ b/src/db/models/db_ledger_entry.rs @@ -19,14 +19,14 @@ pub struct DbLedgerEntry { pub output: Option, pub address: Option, pub receiver_address: Option, - pub amount: PgNumericU128, + pub amount: Option, pub operation: DbLedgerOperation, pub timestamp: PgBigIntU32, } impl DbLedgerEntry { pub fn from_values( - amount: u128, + amount: Option, rune_id: RuneId, block_hash: &String, block_height: u64, @@ -49,7 +49,7 @@ impl DbLedgerEntry { output: output.map(|i| PgBigIntU32(i)), address: address.cloned(), receiver_address: receiver_address.cloned(), - amount: PgNumericU128(amount), + amount: amount.map(|i| PgNumericU128(i)), operation, timestamp: PgBigIntU32(timestamp), } diff --git a/src/db/models/db_ledger_operation.rs b/src/db/models/db_ledger_operation.rs index 372790a..f0ee1ec 100644 --- a/src/db/models/db_ledger_operation.rs +++ b/src/db/models/db_ledger_operation.rs @@ -6,6 +6,7 @@ use tokio_postgres::types::{to_sql_checked, FromSql, IsNull, ToSql, Type}; /// A value from the `ledger_operation` enum type. #[derive(Debug, Clone, PartialEq)] pub enum DbLedgerOperation { + Etching, Mint, Burn, Send, @@ -21,6 +22,7 @@ impl fmt::Display for DbLedgerOperation { impl DbLedgerOperation { pub fn as_str(&self) -> &str { match self { + Self::Etching => "etching", Self::Mint => "mint", Self::Burn => "burn", Self::Send => "send", @@ -34,6 +36,7 @@ impl std::str::FromStr for DbLedgerOperation { fn from_str(s: &str) -> Result { match s { + "etching" => Ok(DbLedgerOperation::Etching), "mint" => Ok(DbLedgerOperation::Mint), "burn" => Ok(DbLedgerOperation::Burn), "send" => Ok(DbLedgerOperation::Send), From 86543f71ef2bb432b7fde4f6bea42dbde507c861 Mon Sep 17 00:00:00 2001 From: Rafael Cardenas Date: Sun, 30 Jun 2024 16:10:21 -0600 Subject: [PATCH 13/14] ci: attempt to add vercel --- .github/workflows/vercel.yml | 79 +++++++++++++++++++++++++++++++++ api/package.json | 3 ++ api/src/api/routes/addresses.ts | 7 +-- api/util/openapi-generator.ts | 9 ++-- vercel.json | 5 +++ 5 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/vercel.yml create mode 100644 vercel.json diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml new file mode 100644 index 0000000..52c63bb --- /dev/null +++ b/.github/workflows/vercel.yml @@ -0,0 +1,79 @@ +name: Vercel + +env: + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + +on: + push: + branches: + - main + - beta + - develop + pull_request: + release: + types: + - published + workflow_dispatch: + +jobs: + vercel: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./api + + environment: + name: ${{ github.ref_name == 'main' && 'Production' || 'Preview' }} + url: ${{ github.ref_name == 'main' && 'https://runes-api.vercel.app/' || 'https://runes-api-pbcblockstack-blockstack.vercel.app/' }} + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version-file: 'api/.nvmrc' + + - name: Cache node modules + uses: actions/cache@v2 + env: + cache-name: cache-node-modules + with: + path: | + ~/.npm + **/node_modules + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-build-${{ env.cache-name }}- + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Install deps + run: npm ci --audit=false + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel environment information + run: vercel pull --yes --environment=${{ github.ref_name == 'main' && 'production' || 'preview' }} --token=${{ secrets.VERCEL_TOKEN }} + + - name: Build project artifacts + run: vercel build ${{ github.ref_name == 'main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }} + + - name: Deploy project artifacts to Vercel + id: deploy + run: vercel ${{ github.ref_name == 'main' && '--prod' || 'deploy' }} --prebuilt --token=${{ secrets.VERCEL_TOKEN }} | awk '{print "deployment_url="$1}' >> $GITHUB_OUTPUT + + - name: Trigger docs.hiro.so deployment + if: github.ref_name == 'main' + run: curl -X POST ${{ secrets.VERCEL_DOCS_DEPLOY_HOOK_URL }} + + - name: Add comment with Vercel deployment URL + if: ${{ github.event_name == 'pull_request' }} + uses: thollander/actions-comment-pull-request@v2 + with: + comment_tag: vercel + message: | + Vercel deployment URL: ${{ steps.deploy.outputs.deployment_url }} :rocket: diff --git a/api/package.json b/api/package.json index 43ab750..a366058 100644 --- a/api/package.json +++ b/api/package.json @@ -8,7 +8,10 @@ "start": "node dist/src/index.js", "start-ts": "ts-node ./src/index.ts", "test": "jest --runInBand", + "generate:openapi": "rimraf ./tmp && node -r ts-node/register ./util/openapi-generator.ts", + "generate:docs": "redoc-cli build --output ./tmp/index.html ./tmp/openapi.yaml", "generate:git-info": "rimraf .git-info && node_modules/.bin/api-toolkit-git-info", + "generate:vercel": "npm run generate:git-info && npm run generate:openapi && npm run generate:docs", "lint:eslint": "eslint . --ext .ts,.tsx -f unix", "lint:prettier": "prettier --check src/**/*.ts tests/**/*.ts", "lint:unused-exports": "ts-unused-exports tsconfig.json --showLineNumber --excludePathsFromReport=util/*" diff --git a/api/src/api/routes/addresses.ts b/api/src/api/routes/addresses.ts index 448550b..29f6870 100644 --- a/api/src/api/routes/addresses.ts +++ b/api/src/api/routes/addresses.ts @@ -2,12 +2,7 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Type } from '@sinclair/typebox'; import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; -import { - AddressSchema, - LimitSchema, - OffsetSchema, - BalanceResponseSchema, -} from '../schemas'; +import { AddressSchema, LimitSchema, OffsetSchema, BalanceResponseSchema } from '../schemas'; import { parseBalanceResponse } from '../util/helpers'; import { Optional, PaginatedResponse } from '@hirosystems/api-toolkit'; import { handleCache } from '../util/cache'; diff --git a/api/util/openapi-generator.ts b/api/util/openapi-generator.ts index 34da066..f955716 100644 --- a/api/util/openapi-generator.ts +++ b/api/util/openapi-generator.ts @@ -15,12 +15,14 @@ export const ApiGenerator: FastifyPluginAsync< TypeBoxTypeProvider > = async (fastify, options) => { await fastify.register(FastifySwagger, OpenApiSchemaOptions); - await fastify.register(Api, { prefix: '/ordinals/v1' }); + await fastify.register(Api, { prefix: '/runes/v1' }); if (!existsSync('./tmp')) { mkdirSync('./tmp'); } - writeFileSync('./tmp/openapi.yaml', fastify.swagger({ yaml: true })); - writeFileSync('./tmp/openapi.json', JSON.stringify(fastify.swagger(), null, 2)); + fastify.addHook('onReady', () => { + writeFileSync('./tmp/openapi.yaml', fastify.swagger({ yaml: true })); + writeFileSync('./tmp/openapi.json', JSON.stringify(fastify.swagger(), null, 2)); + }); }; const fastify = Fastify({ @@ -29,5 +31,6 @@ const fastify = Fastify({ }).withTypeProvider(); void fastify.register(ApiGenerator).then(async () => { + await fastify.ready(); await fastify.close(); }); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..45c873b --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "git": { + "deploymentEnabled": false + } +} From 4be379a58f07c01b48838f49d9b2b7217483c88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20C=C3=A1rdenas?= Date: Mon, 1 Jul 2024 11:33:32 -0600 Subject: [PATCH 14/14] fix: vercel build path (#3) * fix: vercel build path * fix: try with local dir * chore: better docs --- .github/workflows/vercel.yml | 4 +- .gitignore | 1 + api/src/api/routes/addresses.ts | 4 +- api/src/api/routes/blocks.ts | 4 +- api/src/api/routes/etchings.ts | 18 +- api/src/api/routes/transactions.ts | 4 +- api/src/api/schemas.ts | 326 ++++++++++++++++++++++------- api/src/pg/types.ts | 2 +- 8 files changed, 266 insertions(+), 97 deletions(-) diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml index 52c63bb..c643bc3 100644 --- a/.github/workflows/vercel.yml +++ b/.github/workflows/vercel.yml @@ -25,7 +25,7 @@ jobs: environment: name: ${{ github.ref_name == 'main' && 'Production' || 'Preview' }} - url: ${{ github.ref_name == 'main' && 'https://runes-api.vercel.app/' || 'https://runes-api-pbcblockstack-blockstack.vercel.app/' }} + url: ${{ github.ref_name == 'main' && 'https://runes-api.vercel.app/' || 'https://runehook-pbcblockstack-hirosystems.vercel.app/' }} steps: - uses: actions/checkout@v2 @@ -60,7 +60,7 @@ jobs: run: vercel pull --yes --environment=${{ github.ref_name == 'main' && 'production' || 'preview' }} --token=${{ secrets.VERCEL_TOKEN }} - name: Build project artifacts - run: vercel build ${{ github.ref_name == 'main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }} + run: vercel build ${{ github.ref_name == 'main' && '--prod' || '' }} --token=${{ secrets.VERCEL_TOKEN }} --cwd=./ - name: Deploy project artifacts to Vercel id: deploy diff --git a/.gitignore b/.gitignore index 5834ec2..54ec2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Runehook.toml +.DS_Store diff --git a/api/src/api/routes/addresses.ts b/api/src/api/routes/addresses.ts index 29f6870..5dca58d 100644 --- a/api/src/api/routes/addresses.ts +++ b/api/src/api/routes/addresses.ts @@ -19,9 +19,9 @@ export const AddressRoutes: FastifyPluginCallback< { schema: { operationId: 'getAddressBalances', - summary: 'Get address balances', + summary: 'Address balances', description: 'Retrieves a paginated list of address balances', - tags: ['Runes'], + tags: ['Balances'], params: Type.Object({ address: AddressSchema, }), diff --git a/api/src/api/routes/blocks.ts b/api/src/api/routes/blocks.ts index a2d1751..6c49050 100644 --- a/api/src/api/routes/blocks.ts +++ b/api/src/api/routes/blocks.ts @@ -19,9 +19,9 @@ export const BlockRoutes: FastifyPluginCallback< { schema: { operationId: 'getBlockActivity', - summary: 'Get block activity', + summary: 'Block activity', description: 'Retrieves a paginated list of rune activity for a block', - tags: ['Runes'], + tags: ['Activities'], params: Type.Object({ block: BlockSchema, }), diff --git a/api/src/api/routes/etchings.ts b/api/src/api/routes/etchings.ts index 3a2a673..6e37afe 100644 --- a/api/src/api/routes/etchings.ts +++ b/api/src/api/routes/etchings.ts @@ -29,9 +29,9 @@ export const EtchingRoutes: FastifyPluginCallback< { schema: { operationId: 'getEtchings', - summary: 'Get rune etchings', + summary: 'Rune etchings', description: 'Retrieves a paginated list of rune etchings', - tags: ['Runes'], + tags: ['Etchings'], querystring: Type.Object({ offset: Optional(OffsetSchema), limit: Optional(LimitSchema), @@ -61,7 +61,7 @@ export const EtchingRoutes: FastifyPluginCallback< operationId: 'getEtching', summary: 'Rune etching', description: 'Retrieves information for a Rune etching', - tags: ['Runes'], + tags: ['Etchings'], params: Type.Object({ etching: RuneSchema, }), @@ -86,9 +86,9 @@ export const EtchingRoutes: FastifyPluginCallback< { schema: { operationId: 'getRuneActivity', - summary: 'Rune rune activity', + summary: 'Rune activity', description: 'Retrieves all activity for a Rune', - tags: ['Runes'], + tags: ['Activities'], params: Type.Object({ etching: RuneSchema, }), @@ -119,9 +119,9 @@ export const EtchingRoutes: FastifyPluginCallback< { schema: { operationId: 'getRuneAddressActivity', - summary: 'Rune rune activity for address', + summary: 'Rune activity for address', description: 'Retrieves all activity for a Rune address', - tags: ['Runes'], + tags: ['Activities'], params: Type.Object({ etching: RuneSchema, address: AddressSchema, @@ -160,7 +160,7 @@ export const EtchingRoutes: FastifyPluginCallback< operationId: 'getRuneHolders', summary: 'Rune holders', description: 'Retrieves a paginated list of holders for a Rune', - tags: ['Runes'], + tags: ['Balances'], params: Type.Object({ etching: RuneSchema, }), @@ -193,7 +193,7 @@ export const EtchingRoutes: FastifyPluginCallback< operationId: 'getRuneHolderBalance', summary: 'Rune holder balance', description: 'Retrieves holder balance for a specific Rune', - tags: ['Runes'], + tags: ['Balances'], params: Type.Object({ etching: RuneSchema, address: AddressSchema, diff --git a/api/src/api/routes/transactions.ts b/api/src/api/routes/transactions.ts index da78387..1baf14d 100644 --- a/api/src/api/routes/transactions.ts +++ b/api/src/api/routes/transactions.ts @@ -19,9 +19,9 @@ export const TransactionRoutes: FastifyPluginCallback< { schema: { operationId: 'getTransactionActivity', - summary: 'Get transaction activity', + summary: 'Transaction activity', description: 'Retrieves a paginated list of rune activity for a transaction', - tags: ['Runes'], + tags: ['Activities'], params: Type.Object({ tx_id: TransactionIdSchema, }), diff --git a/api/src/api/schemas.ts b/api/src/api/schemas.ts index eebcd81..ce257b1 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -7,7 +7,7 @@ export const OpenApiSchemaOptions: SwaggerOptions = { openapi: { info: { title: 'Runes API', - description: ``, + description: `REST API to get information about Runes`, version: SERVER_VERSION.tag, }, externalDocs: { @@ -22,17 +22,25 @@ export const OpenApiSchemaOptions: SwaggerOptions = { ], tags: [ { - name: 'Runes', - description: '', + name: 'Etchings', + description: 'Rune etchings', + }, + { + name: 'Activities', + description: 'Rune activities', + }, + { + name: 'Balances', + description: 'Rune balances', + }, + { + name: 'Status', + description: 'API status', }, ], }, }; -// ========================== -// Parameters -// ========================== - export const OffsetSchema = Type.Integer({ minimum: 0, title: 'Offset', @@ -48,12 +56,12 @@ export const LimitSchema = Type.Integer({ }); export type Limit = Static; -const RuneIdSchema = Type.RegEx(/^[0-9]+:[0-9]+$/); -const RuneNumberSchema = Type.RegEx(/^[0-9]+$/); +const RuneIdSchema = Type.RegEx(/^[0-9]+:[0-9]+$/, { title: 'Rune ID' }); +const RuneNumberSchema = Type.RegEx(/^[0-9]+$/, { title: 'Rune number' }); export const RuneNumberSchemaCType = TypeCompiler.Compile(RuneNumberSchema); -const RuneNameSchema = Type.RegEx(/^[A-Z]+$/); +const RuneNameSchema = Type.RegEx(/^[A-Z]+$/, { title: 'Rune name' }); export const RuneNameSchemaCType = TypeCompiler.Compile(RuneNameSchema); -const RuneSpacedNameSchema = Type.RegEx(/^[A-Z](•[A-Z]+)+$/); +const RuneSpacedNameSchema = Type.RegEx(/^[A-Z](•[A-Z]+)+$/, { title: 'Rune name with spacers' }); export const RuneSpacedNameSchemaCType = TypeCompiler.Compile(RuneSpacedNameSchema); export const RuneSchema = Type.Union([ @@ -78,27 +86,26 @@ export const TransactionIdSchema = Type.RegEx(/^[a-fA-F0-9]{64}$/, { }); export type TransactionId = Static; -export const TransactionOutputSchema = Type.RegEx(/^[a-fA-F0-9]{64}:[0-9]+$/, { - title: 'Transaction Output', - description: 'A transaction output', - examples: ['8f46f0d4ef685e650727e6faf7e30f23b851a7709714ec774f7909b3fb5e604c:0'], -}); -export type TransactionOutput = Static; +// const TransactionOutputSchema = Type.RegEx(/^[a-fA-F0-9]{64}:[0-9]+$/, { +// title: 'Transaction Output', +// description: 'A transaction output', +// examples: ['8f46f0d4ef685e650727e6faf7e30f23b851a7709714ec774f7909b3fb5e604c:0'], +// }); +// type TransactionOutput = Static; -export const BlockHeightSchema = Type.RegEx(/^[0-9]+$/, { +const BlockHeightSchema = Type.RegEx(/^[0-9]+$/, { title: 'Block Height', description: 'Bitcoin block height', examples: [777678], }); export const BlockHeightCType = TypeCompiler.Compile(BlockHeightSchema); -export type BlockHeight = Static; const BlockHashSchema = Type.RegEx(/^[0]{8}[a-fA-F0-9]{56}$/, { title: 'Block Hash', description: 'Bitcoin block hash', examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'], }); -export type BlockHash = Static; +type BlockHash = Static; export const BlockSchema = Type.Union([BlockHeightSchema, BlockHashSchema]); export type Block = Static; @@ -116,78 +123,230 @@ export const ApiStatusResponse = Type.Object( { title: 'Api Status Response' } ); -const LocationDetailResponseSchema = Type.Object({ - block_hash: Type.String({ - examples: ['00000000000000000000c9787573a1f1775a2b56b403a2d0c7957e9a5bc754bb'], - }), - block_height: Type.Integer({ examples: [840000] }), - tx_id: Type.String({ - examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e'], - }), - tx_index: Type.Integer({ examples: [1] }), - vout: Optional(Type.Integer({ examples: [100] })), - output: Optional( - Type.String({ - examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e:100'], - }) - ), - timestamp: Type.Integer({ examples: [1713571767] }), +const LocationDetailResponseSchema = Type.Object( + { + block_hash: Type.String({ + examples: ['00000000000000000000c9787573a1f1775a2b56b403a2d0c7957e9a5bc754bb'], + title: 'Block hash', + description: 'Bitcoin block hash', + }), + block_height: Type.Integer({ + examples: [840000], + title: 'Block height', + description: 'Bitcoin block height', + }), + tx_id: Type.String({ + examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e'], + title: 'Transaction ID', + description: 'Bitcoin transaction ID', + }), + tx_index: Type.Integer({ + examples: [1], + title: 'Transaction Index', + description: 'Index of this transaction in its Bitcoin block', + }), + vout: Optional( + Type.Integer({ + examples: [100], + title: 'Output number', + description: 'Bitcoin transaction output number', + }) + ), + output: Optional( + Type.String({ + examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e:100'], + title: 'Transaction output', + description: 'Bitcoin transaction output', + }) + ), + timestamp: Type.Integer({ + examples: [1713571767], + title: 'Timestamp', + description: 'Bitcoin transaction timestamp', + }), + }, + { + title: 'Transaction location', + description: 'Location of the transaction which confirmed this operation', + } +); + +const RuneIdResponseSchema = Type.String({ + title: 'ID', + description: 'Rune ID', + examples: ['840000:1'], +}); + +const RuneNameResponseSchema = Type.String({ + title: 'Name', + description: 'Rune name', + examples: ['ZZZZZFEHUZZZZZ'], +}); + +const RuneSpacedNameResponseSchema = Type.String({ + title: 'Spaced name', + description: 'Rune name with spacers', + examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'], +}); + +const RuneNumberResponseSchema = Type.Integer({ + title: 'Number', + description: 'Rune number', + examples: [1], }); export const EtchingResponseSchema = Type.Object({ - id: Type.String({ examples: ['840000:1'] }), - name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), - spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), - number: Type.Integer({ examples: [1] }), - divisibility: Type.Integer({ examples: [2] }), - symbol: Type.String({ examples: ['ᚠ'] }), - turbo: Type.Boolean({ examples: [false] }), - mint_terms: Type.Object({ - amount: Nullable(Type.String({ examples: ['100'] })), - cap: Nullable(Type.String({ examples: ['1111111'] })), - height_start: Nullable(Type.Integer({ examples: [840000] })), - height_end: Nullable(Type.Integer({ examples: [1050000] })), - offset_start: Nullable(Type.Integer({ examples: [0] })), - offset_end: Nullable(Type.Integer({ examples: [200] })), - }), - supply: Type.Object({ - current: Type.String({ examples: ['11274916350'] }), - minted: Type.String({ examples: ['274916100'] }), - total_mints: Type.String({ examples: ['250'] }), - mint_percentage: Type.String({ examples: ['59.4567'] }), - mintable: Type.Boolean(), - burned: Type.String({ examples: ['5100'] }), - total_burns: Type.String({ examples: ['17'] }), - premine: Type.String({ examples: ['11000000000'] }), + id: RuneIdResponseSchema, + name: RuneNameResponseSchema, + spaced_name: RuneSpacedNameResponseSchema, + number: RuneNumberResponseSchema, + divisibility: Type.Integer({ + title: 'Divisibility', + description: 'Rune decimal places', + examples: [2], }), + symbol: Type.String({ title: 'Symbol', description: 'Rune symbol', examples: ['ᚠ'] }), + turbo: Type.Boolean({ title: 'Turbo', description: 'Rune upgradeability', examples: [false] }), + mint_terms: Type.Object( + { + amount: Nullable( + Type.String({ + examples: ['100'], + title: 'Mint amount', + description: 'Amount awarded per mint', + }) + ), + cap: Nullable( + Type.String({ + examples: ['1111111'], + title: 'Mint cap', + description: 'Maximum number of mints allowed', + }) + ), + height_start: Nullable( + Type.Integer({ + examples: [840000], + title: 'Mint block height start', + description: 'Block height at which the mint period opens', + }) + ), + height_end: Nullable( + Type.Integer({ + examples: [1050000], + title: 'Mint block height end', + description: 'Block height at which the mint period closes', + }) + ), + offset_start: Nullable( + Type.Integer({ + examples: [0], + title: 'Mint block height offset start', + description: 'Block height etching offset at which the mint period opens', + }) + ), + offset_end: Nullable( + Type.Integer({ + examples: [200], + title: 'Mint block height offset end', + description: 'Block height etching offset at which the mint period closes', + }) + ), + }, + { title: 'Mint terms', description: 'Rune mint terms' } + ), + supply: Type.Object( + { + current: Type.String({ + examples: ['11274916350'], + title: 'Current supply', + description: 'Circulating supply including mints, burns and premine', + }), + minted: Type.String({ + examples: ['274916100'], + title: 'Minted amount', + description: 'Total minted amount', + }), + total_mints: Type.String({ + examples: ['250'], + title: 'Total mints', + description: 'Number of mints for this rune', + }), + mint_percentage: Type.String({ + examples: ['59.4567'], + title: 'Mint percentage', + description: 'Percentage of mints that have been claimed', + }), + mintable: Type.Boolean({ + title: 'Mintable', + description: 'Whether or not this rune is mintable at this time', + }), + burned: Type.String({ + examples: ['5100'], + title: 'Burned amount', + description: 'Total burned amount', + }), + total_burns: Type.String({ + examples: ['17'], + title: 'Total burns', + description: 'Number of burns for this rune', + }), + premine: Type.String({ + examples: ['11000000000'], + title: 'Premine amount', + description: 'Amount premined for this rune', + }), + }, + { title: 'Supply information', description: 'Rune supply information' } + ), location: LocationDetailResponseSchema, }); export type EtchingResponse = Static; const RuneDetailResponseSchema = Type.Object({ - rune: Type.Object({ - id: Type.String({ examples: ['840000:1'] }), - name: Type.String({ examples: ['ZZZZZFEHUZZZZZ'] }), - spaced_name: Type.String({ examples: ['Z•Z•Z•Z•Z•FEHU•Z•Z•Z•Z•Z'] }), - }), + rune: Type.Object( + { + id: RuneIdResponseSchema, + name: RuneNameResponseSchema, + spaced_name: RuneSpacedNameResponseSchema, + }, + { title: 'Rune detail', description: 'Details of the rune affected by this activity' } + ), }); export const SimpleActivityResponseSchema = Type.Object({ - address: Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), + address: Optional( + Type.String({ + examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'], + title: 'Address', + description: 'Bitcoin address which initiated this activity', + }) + ), receiver_address: Optional( - Type.String({ examples: ['bc1pgdrveee2v4ez95szaakw5gkd8eennv2dddf9rjdrlt6ch56lzrrsxgvazt'] }) + Type.String({ + examples: ['bc1pgdrveee2v4ez95szaakw5gkd8eennv2dddf9rjdrlt6ch56lzrrsxgvazt'], + title: 'Receiver address', + description: 'Bitcoin address which is receiving rune balance', + }) + ), + amount: Optional( + Type.String({ + examples: ['11000000000'], + title: 'Amount', + description: 'Rune amount relevat to this activity', + }) + ), + operation: Type.Union( + [ + Type.Literal('etching'), + Type.Literal('mint'), + Type.Literal('burn'), + Type.Literal('send'), + Type.Literal('receive'), + ], + { title: 'Operation', description: 'Type of operation described in this activity' } ), - amount: Optional(Type.String({ examples: ['11000000000'] })), - operation: Type.Union([ - Type.Literal('etching'), - Type.Literal('mint'), - Type.Literal('burn'), - Type.Literal('send'), - Type.Literal('receive'), - ]), location: LocationDetailResponseSchema, }); -export type SimpleActivityResponse = Static; export const ActivityResponseSchema = Type.Intersect([ RuneDetailResponseSchema, @@ -196,10 +355,19 @@ export const ActivityResponseSchema = Type.Intersect([ export type ActivityResponse = Static; export const SimpleBalanceResponseSchema = Type.Object({ - address: Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), - balance: Type.String({ examples: ['11000000000'] }), + address: Optional( + Type.String({ + examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'], + title: 'Address', + description: 'Bitcoin address which holds this balance', + }) + ), + balance: Type.String({ + examples: ['11000000000'], + title: 'Balance', + description: 'Rune balance', + }), }); -export type SimpleBalanceResponse = Static; export const BalanceResponseSchema = Type.Intersect([ RuneDetailResponseSchema, diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index ccaffe8..b5d41d8 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -5,7 +5,7 @@ export type DbPaginatedResult = { export type DbCountedQueryResult = T & { total: number }; -export type DbRune = { +type DbRune = { id: string; number: number; name: string;