diff --git a/.github/workflows/vercel.yml b/.github/workflows/vercel.yml new file mode 100644 index 0000000..c643bc3 --- /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://runehook-pbcblockstack-hirosystems.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 }} --cwd=./ + + - 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/.gitignore b/.gitignore index 5834ec2..54ec2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Runehook.toml +.DS_Store 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..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/*" @@ -48,7 +51,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 9d7c832..93a9065 100644 --- a/api/src/api/init.ts +++ b/api/src/api/init.ts @@ -1,51 +1,32 @@ -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'; +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); + await fastify.register(BlockRoutes); }; 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/addresses.ts b/api/src/api/routes/addresses.ts new file mode 100644 index 0000000..5dca58d --- /dev/null +++ b/api/src/api/routes/addresses.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 { AddressSchema, LimitSchema, OffsetSchema, 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', handleCache); + + fastify.get( + '/addresses/:address/balances', + { + schema: { + operationId: 'getAddressBalances', + summary: 'Address balances', + description: 'Retrieves a paginated list of address balances', + tags: ['Balances'], + params: Type.Object({ + address: AddressSchema, + }), + querystring: Type.Object({ + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), + }), + 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/blocks.ts b/api/src/api/routes/blocks.ts new file mode 100644 index 0000000..6c49050 --- /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: 'Block activity', + description: 'Retrieves a paginated list of rune activity for a block', + tags: ['Activities'], + 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 e172286..6e37afe 100644 --- a/api/src/api/routes/etchings.ts +++ b/api/src/api/routes/etchings.ts @@ -4,39 +4,37 @@ import { Value } from '@sinclair/typebox/value'; import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; import { - BalanceResponseSchema, - EtchingActivityResponseSchema, - EtchingParamSchema, + AddressSchema, + RuneSchema, EtchingResponseSchema, - LimitParamSchema, + LimitSchema, NotFoundResponse, - OffsetParamSchema, - PaginatedResponse, + 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'; export const EtchingRoutes: FastifyPluginCallback< Record, Server, TypeBoxTypeProvider > = (fastify, options, done) => { - // fastify.addHook('preHandler', handleInscriptionTransfersCache); + fastify.addHook('preHandler', handleCache); fastify.get( '/etchings', { 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: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { 200: PaginatedResponse(EtchingResponseSchema, 'Paginated etchings response'), @@ -63,9 +61,9 @@ export const EtchingRoutes: FastifyPluginCallback< operationId: 'getEtching', summary: 'Rune etching', description: 'Retrieves information for a Rune etching', - tags: ['Runes'], + tags: ['Etchings'], params: Type.Object({ - etching: EtchingParamSchema, + etching: RuneSchema, }), response: { 200: EtchingResponseSchema, @@ -87,31 +85,70 @@ export const EtchingRoutes: FastifyPluginCallback< '/etchings/:etching/activity', { schema: { - operationId: 'getEtchingActivity', - summary: 'Rune etching activity', + operationId: 'getRuneActivity', + summary: 'Rune activity', description: 'Retrieves all activity for a Rune', - tags: ['Runes'], + tags: ['Activities'], params: Type.Object({ - etching: EtchingParamSchema, + etching: RuneSchema, }), querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), 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)), + results: results.results.map(r => parseActivityResponse(r)), + }); + } + ); + + fastify.get( + '/etchings/:etching/activity/:address', + { + schema: { + operationId: 'getRuneAddressActivity', + summary: 'Rune activity for address', + description: 'Retrieves all activity for a Rune address', + tags: ['Activities'], + params: Type.Object({ + etching: RuneSchema, + address: AddressSchema, + }), + querystring: Type.Object({ + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), + }), + 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, + total: results.total, + results: results.results.map(r => parseActivityResponse(r)), }); } ); @@ -123,16 +160,16 @@ 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: EtchingParamSchema, + etching: RuneSchema, }), querystring: Type.Object({ - offset: Type.Optional(OffsetParamSchema), - limit: Type.Optional(LimitParamSchema), + offset: Optional(OffsetSchema), + limit: Optional(LimitSchema), }), response: { - 200: PaginatedResponse(BalanceResponseSchema, 'Paginated holders response'), + 200: PaginatedResponse(SimpleBalanceResponseSchema, 'Paginated holders response'), }, }, }, @@ -149,5 +186,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: ['Balances'], + params: Type.Object({ + etching: RuneSchema, + address: AddressSchema, + }), + 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/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/routes/transactions.ts b/api/src/api/routes/transactions.ts new file mode 100644 index 0000000..1baf14d --- /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: 'Transaction activity', + description: 'Retrieves a paginated list of rune activity for a transaction', + tags: ['Activities'], + 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 8da6df0..ce257b1 100644 --- a/api/src/api/schemas.ts +++ b/api/src/api/schemas.ts @@ -1,13 +1,13 @@ import { SwaggerOptions } from '@fastify/swagger'; -import { SERVER_VERSION } from '@hirosystems/api-toolkit'; -import { Static, TSchema, Type } from '@sinclair/typebox'; +import { Nullable, Optional, SERVER_VERSION } from '@hirosystems/api-toolkit'; +import { Static, Type } from '@sinclair/typebox'; import { TypeCompiler } from '@sinclair/typebox/compiler'; export const OpenApiSchemaOptions: SwaggerOptions = { openapi: { info: { title: 'Runes API', - description: ``, + description: `REST API to get information about Runes`, version: SERVER_VERSION.tag, }, externalDocs: { @@ -22,125 +22,357 @@ 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', }, ], }, }; -const Nullable = (type: T) => Type.Union([type, Type.Null()]); - -// ========================== -// 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]+$/); +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]+$/, { 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 EtchingParamSchema = Type.Union([RuneIdSchema, RuneNameSchema, RuneSpacedNameSchema]); -export type EtchingParam = Static; +export const RuneSchema = Type.Union([ + RuneIdSchema, + RuneNumberSchema, + RuneNameSchema, + RuneSpacedNameSchema, +]); +export type Rune = 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; + +// const TransactionOutputSchema = Type.RegEx(/^[a-fA-F0-9]{64}:[0-9]+$/, { +// title: 'Transaction Output', +// description: 'A transaction output', +// examples: ['8f46f0d4ef685e650727e6faf7e30f23b851a7709714ec774f7909b3fb5e604c:0'], +// }); +// type TransactionOutput = Static; + +const BlockHeightSchema = Type.RegEx(/^[0-9]+$/, { + title: 'Block Height', + description: 'Bitcoin block height', + examples: [777678], +}); +export const BlockHeightCType = TypeCompiler.Compile(BlockHeightSchema); + +const BlockHashSchema = Type.RegEx(/^[0]{8}[a-fA-F0-9]{56}$/, { + title: 'Block Hash', + description: 'Bitcoin block hash', + examples: ['0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133'], +}); +type BlockHash = Static; + +export const BlockSchema = Type.Union([BlockHeightSchema, BlockHashSchema]); +export type Block = 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 ApiStatusResponse = Type.Object( + { + server_version: Type.String({ examples: [''] }), + status: Type.String(), + block_height: Optional(Type.Integer()), + }, + { title: 'Api Status Response' } +); + +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] }), - block_height: Type.Integer({ examples: [840000] }), - tx_index: Type.Integer({ examples: [1] }), - tx_id: Type.String({ - examples: ['2bb85f4b004be6da54f766c17c1e855187327112c231ef2ff35ebad0ea67c69e'], - }), - divisibility: Type.Integer({ examples: [2] }), - premine: Type.String({ examples: ['11000000000'] }), - symbol: Type.String({ examples: ['ᚠ'] }), - 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] })), + id: RuneIdResponseSchema, + name: RuneNameResponseSchema, + spaced_name: RuneSpacedNameResponseSchema, + number: RuneNumberResponseSchema, + divisibility: Type.Integer({ + title: 'Divisibility', + description: 'Rune decimal places', + examples: [2], }), - 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] }), + 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({ - 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 EtchingActivityResponseSchema = Type.Object({ - rune: RuneDetailResponseSchema, - 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: Type.Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), - receiver_address: Type.Optional( - Type.String({ examples: ['bc1pgdrveee2v4ez95szaakw5gkd8eennv2dddf9rjdrlt6ch56lzrrsxgvazt'] }) +export const SimpleActivityResponseSchema = Type.Object({ + address: Optional( + Type.String({ + examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'], + title: 'Address', + description: 'Bitcoin address which initiated this activity', + }) + ), + receiver_address: Optional( + Type.String({ + examples: ['bc1pgdrveee2v4ez95szaakw5gkd8eennv2dddf9rjdrlt6ch56lzrrsxgvazt'], + title: 'Receiver address', + description: 'Bitcoin address which is receiving rune balance', + }) ), - amount: Type.String({ examples: ['11000000000'] }), - operation: Type.Union([ - Type.Literal('mint'), - Type.Literal('burn'), - Type.Literal('send'), - Type.Literal('receive'), - ]), - timestamp: Type.Integer({ examples: [1713571767] }), + 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' } + ), + location: LocationDetailResponseSchema, }); -export type EtchingActivityResponse = Static; -export const BalanceResponseSchema = Type.Object({ - rune: RuneDetailResponseSchema, - address: Type.Optional(Type.String({ examples: ['bc1q7jd477wc5s88hsvenr0ddtatsw282hfjzg59wz'] })), - balance: Type.String({ examples: ['11000000000'] }), +export const ActivityResponseSchema = Type.Intersect([ + RuneDetailResponseSchema, + SimpleActivityResponseSchema, +]); +export type ActivityResponse = Static; + +export const SimpleBalanceResponseSchema = Type.Object({ + 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 const BalanceResponseSchema = Type.Intersect([ + RuneDetailResponseSchema, + SimpleBalanceResponseSchema, +]); export type BalanceResponse = Static; export const NotFoundResponse = Type.Object( 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 bb3aa01..28fe0de 100644 --- a/api/src/api/util/helpers.ts +++ b/api/src/api/util/helpers.ts @@ -1,59 +1,86 @@ import BigNumber from 'bignumber.js'; -import { DbBalance, DbItemWithRune, DbLedgerEntry, DbRune } from '../../pg/types'; -import { EtchingResponse, EtchingActivityResponse, BalanceResponse } from '../schemas'; +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: DbRune): EtchingResponse { +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 && 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; + } return { id: rune.id, number: rune.number, name: rune.name, spaced_name: rune.spaced_name, - block_height: 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, 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: { + premine: divisibility(rune.premine, rune.divisibility), + 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), + 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, + 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, + }, }; } -export function parseEtchingActivityResponse( - entry: DbItemWithRune -): EtchingActivityResponse { +export function parseActivityResponse(entry: DbItemWithRune): ActivityResponse { return { rune: { id: entry.rune_id, name: entry.name, spaced_name: entry.spaced_name, }, - block_height: 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/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 3ad14e3..a1d92bd 100644 --- a/api/src/pg/pg-store.ts +++ b/api/src/pg/pg-store.ts @@ -12,25 +12,41 @@ import { DbItemWithRune, DbLedgerEntry, DbPaginatedResult, - DbRune, + DbRuneWithChainTip, } from './types'; import { - EtchingParam, - LimitParam, - OffsetParam, + Address, + BlockHeightCType, + Block, + Rune, + Limit, + Offset, RuneNameSchemaCType, RuneSpacedNameSchemaCType, + TransactionId, + RuneNumberSchemaCType, } 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}`; + } else if (RuneNumberSchemaCType.Check(etching)) { + filter = sql`${sql(`${p}number`)} = ${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 { @@ -59,18 +75,35 @@ export class PgStore extends BasePgStore { super(sql); } - async getEtching(id: EtchingParam): Promise { - const result = await this.sql` - SELECT * FROM runes WHERE ${getEtchingIdWhereCondition(this.sql, id)} + 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 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 + FROM runes WHERE ${runeFilter(this.sql, id)} `; if (result.count == 0) return undefined; return result[0]; } - async getEtchings(offset: OffsetParam, limit: LimitParam): 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 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 OFFSET ${offset} LIMIT ${limit} @@ -81,17 +114,18 @@ export class PgStore extends BasePgStore { }; } - async getEtchingActivity( - 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 { @@ -100,16 +134,47 @@ export class PgStore extends BasePgStore { }; } + 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} `; @@ -118,4 +183,36 @@ export class PgStore extends BasePgStore { results, }; } + + async getRuneAddressBalance( + 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 ${runeFilter(this.sql, id, 'r')} AND address = ${address} + `; + return results[0]; + } + + async getAddressBalances( + 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 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, + }; + } } diff --git a/api/src/pg/types.ts b/api/src/pg/types.ts index 7718985..b5d41d8 100644 --- a/api/src/pg/types.ts +++ b/api/src/pg/types.ts @@ -5,12 +5,13 @@ export type DbPaginatedResult = { export type DbCountedQueryResult = T & { total: number }; -export type DbRune = { +type DbRune = { id: string; number: number; name: string; spaced_name: string; - block_height: number; + block_hash: string; + block_height: string; tx_index: number; tx_id: string; divisibility: number; @@ -18,30 +19,33 @@ 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: number; + total_mints: string; burned: string; - total_burns: number; - total_operations: number; + total_burns: string; + total_operations: string; timestamp: number; }; -type DbLedgerOperation = 'mint' | 'burn' | 'send' | 'receive'; +export type DbRuneWithChainTip = DbRune & { chain_tip: string }; + +type DbLedgerOperation = 'etching' | 'mint' | 'burn' | 'send' | 'receive'; export type DbLedgerEntry = { rune_id: string; - block_height: number; + block_hash: string; + 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/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/migrations/V1__runes.sql b/migrations/V1__runes.sql index 6a1164a..b0a4eb8 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, @@ -17,20 +18,21 @@ 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); -- 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..aaf4fdf 100644 --- a/migrations/V2__ledger.sql +++ b/migrations/V2__ledger.sql @@ -1,7 +1,8 @@ -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, + block_hash TEXT NOT NULL, block_height NUMERIC NOT NULL, tx_index BIGINT NOT NULL, event_index BIGINT NOT NULL, @@ -9,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/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 2017d94..0d9ca25 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,8 +51,9 @@ 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, 1, 0, &"".to_string(), 0), + tx_cache: TransactionCache::new(network, &"".to_string(), 1, 0, &"".to_string(), 0), db_cache: DbCache::new(), } } @@ -58,27 +61,25 @@ 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. 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 {} {} at block {}", - entry.rune_id.clone(), - entry.amount.0, - entry.block_height.0 - ); - } self.add_ledger_entries_to_db_cache(&entries); } @@ -92,10 +93,7 @@ impl IndexCache { ) { debug!( ctx.expect_logger(), - "Runestone in tx {} ({}) at block {}", - self.tx_cache.tx_id, - self.tx_cache.tx_index, - self.tx_cache.block_height + "{:?} {}", runestone, self.tx_cache.location ); self.scan_tx_input_rune_balance(tx_inputs, db_tx, ctx).await; self.tx_cache @@ -111,10 +109,7 @@ impl IndexCache { ) { debug!( ctx.expect_logger(), - "Cenotaph in tx {} ({}) at block {}", - self.tx_cache.tx_id, - self.tx_cache.tx_index, - self.tx_cache.block_height + "{:?} {}", 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); @@ -127,15 +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 {} 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); + self.add_ledger_entries_to_db_cache(&vec![entry]); self.next_rune_number += 1; } @@ -145,17 +139,16 @@ 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!( 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); + self.add_ledger_entries_to_db_cache(&vec![entry]); self.next_rune_number += 1; } @@ -168,19 +161,25 @@ 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 - ); - 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( @@ -192,26 +191,32 @@ 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(), - ledger_entry.amount.0, - ledger_entry.block_height.0 - ); - 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) { 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; }; @@ -219,10 +224,10 @@ 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.unwrap().0, + self.tx_cache.location ); } self.add_ledger_entries_to_db_cache(&entries); @@ -250,6 +255,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, @@ -293,7 +324,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 @@ -302,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 @@ -334,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(), )); } } @@ -352,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(), )); } @@ -365,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/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 0ad3ad2..033b86f 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}, + vec, +}; use bitcoin::{Address, Network, ScriptBuf}; use chainhook_sdk::{types::bitcoin::TxOut, utils::Context}; @@ -11,6 +14,8 @@ use crate::db::{ types::pg_numeric_u128::PgNumericU128, }; +use super::transaction_location::TransactionLocation; + #[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,11 +26,7 @@ pub struct InputRuneBalance { /// Holds cached data relevant to a single transaction during indexing. pub struct TransactionCache { - network: Network, - 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 @@ -44,18 +45,22 @@ pub struct TransactionCache { impl TransactionCache { pub fn new( network: Network, + block_hash: &String, block_height: u64, tx_index: u32, tx_id: &String, timestamp: u32, ) -> Self { TransactionCache { - network, - 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(), @@ -68,7 +73,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; } @@ -86,7 +100,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; }; @@ -101,7 +115,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() { @@ -119,16 +133,13 @@ impl TransactionCache { for (rune_id, unallocated) in self.input_runes.iter() { for balance in unallocated { results.push(new_ledger_entry( - balance.amount, + &self.location, + Some(balance.amount), *rune_id, - self.block_height, - self.tx_index, - &self.tx_id, None, balance.address.as_ref(), None, DbLedgerOperation::Burn, - self.timestamp, &mut self.next_event_index, )); } @@ -142,17 +153,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.network, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, self.pointer, rune_id, unallocated, &self.eligible_outputs, - 0, + 0, // All of it &mut self.next_event_index, ctx, )); @@ -161,19 +178,13 @@ impl TransactionCache { results } - 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_height, - self.tx_index, - &self.tx_id, - self.timestamp, - ); + 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()); // Move pre-mined balance to input runes. if let Some(premine) = etching.premine { @@ -185,69 +196,109 @@ 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) { - let rune_id = RuneId { - block: self.block_height, - tx: self.tx_index, - }; + 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.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) + 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(&mut self, rune_id: &RuneId, db_rune: &DbRune) -> DbLedgerEntry { - // TODO: What's the default mint amount if none was provided? - let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + pub fn apply_mint( + &mut self, + rune_id: &RuneId, + total_mints: u128, + db_rune: &DbRune, + ctx: &Context, + ) -> Option { + if !is_valid_mint(db_rune, total_mints, &self.location) { + debug!( + ctx.expect_logger(), + "Invalid mint {} {}", rune_id, self.location + ); + 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 + ); self.add_input_runes( rune_id, InputRuneBalance { address: None, - amount: mint_amount.0, + amount: terms_amount.0, }, ); - new_ledger_entry( - mint_amount.0, + Some(new_ledger_entry( + &self.location, + Some(terms_amount.0), rune_id.clone(), - self.block_height, - self.tx_index, - &self.tx_id, None, None, None, DbLedgerOperation::Mint, - self.timestamp, &mut self.next_event_index, - ) + )) } - pub fn apply_cenotaph_mint(&mut self, rune_id: &RuneId, db_rune: &DbRune) -> DbLedgerEntry { - // TODO: What's the default mint amount if none was provided? - let mint_amount = db_rune.terms_amount.unwrap_or(PgNumericU128(0)); + pub fn apply_cenotaph_mint( + &mut self, + rune_id: &RuneId, + total_mints: u128, + db_rune: &DbRune, + ctx: &Context, + ) -> Option { + if !is_valid_mint(db_rune, total_mints, &self.location) { + debug!( + ctx.expect_logger(), + "Invalid mint {} {}", rune_id, self.location + ); + 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 + ); // This entry does not go in the input runes, it gets burned immediately. - new_ledger_entry( - mint_amount.0, + Some(new_ledger_entry( + &self.location, + Some(terms_amount.0), rune_id.clone(), - self.block_height, - self.tx_index, - &self.tx_id, None, None, None, DbLedgerOperation::Burn, - self.timestamp, &mut self.next_event_index, - ) + )) } pub fn apply_edict(&mut self, edict: &Edict, ctx: &Context) -> Vec { @@ -256,7 +307,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![]; }; @@ -268,7 +319,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![]; }; @@ -284,14 +335,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_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, None, // This will force a burn. &rune_id, available_inputs, @@ -321,11 +368,7 @@ impl TransactionCache { remainder -= 1; } results.extend(move_rune_balance_to_output( - self.network, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, Some(output), &rune_id, available_inputs, @@ -340,11 +383,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_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, Some(output), &rune_id, available_inputs, @@ -363,11 +402,7 @@ impl TransactionCache { amount = unallocated; } results.extend(move_rune_balance_to_output( - self.network, - self.block_height, - &self.tx_id, - self.tx_index, - self.timestamp, + &self.location, Some(edict.output), &rune_id, available_inputs, @@ -380,12 +415,21 @@ 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 + 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, + )); } } } @@ -403,31 +447,63 @@ 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; + } + } + 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( - amount: u128, + location: &TransactionLocation, + amount: Option, rune_id: RuneId, - 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_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 @@ -437,11 +513,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_height: u64, - tx_id: &String, - tx_index: u32, - timestamp: u32, + location: &TransactionLocation, output: Option, rune_id: &RuneId, available_inputs: &mut VecDeque, @@ -454,12 +526,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 } @@ -467,7 +539,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 } @@ -480,8 +552,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. @@ -494,20 +568,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( - balance_taken, - *rune_id, - 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, - )); + 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. @@ -525,18 +586,165 @@ 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( - total_sent, + location, + Some(total_sent), *rune_id, - block_height, - tx_index, - &tx_id, output, receiver_address.as_ref(), None, DbLedgerOperation::Receive, - timestamp, 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, + Some(*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 test_case::test_case; + + use bitcoin::ScriptBuf; + use chainhook_sdk::utils::Context; + use ordinals::RuneId; + + 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::{is_valid_mint, move_rune_balance_to_output, InputRuneBalance}; + + #[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.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.unwrap().0, 1000u128); + + 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/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..d76b807 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, @@ -334,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_ledger_entry.rs b/src/db/models/db_ledger_entry.rs index 6986918..ec171bb 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, @@ -18,15 +19,16 @@ 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, 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), @@ -46,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), } @@ -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_ledger_operation.rs b/src/db/models/db_ledger_operation.rs index b67ce23..f0ee1ec 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}; @@ -6,15 +6,23 @@ 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, 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 { + Self::Etching => "etching", Self::Mint => "mint", Self::Burn => "burn", Self::Send => "send", @@ -28,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), diff --git a/src/db/models/db_rune.rs b/src/db/models/db_rune.rs index 0c17044..fefa953 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_location::TransactionLocation, + types::{ + pg_bigint_u32::PgBigIntU32, pg_numeric_u128::PgNumericU128, pg_numeric_u64::PgNumericU64, + pg_smallint_u8::PgSmallIntU8, + }, }; /// A row in the `runes` table. @@ -13,6 +16,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, @@ -27,25 +31,18 @@ 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, } impl DbRune { - pub fn from_etching( - etching: &Etching, - number: u32, - 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() @@ -68,13 +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_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)) @@ -95,30 +93,24 @@ 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), - timestamp: PgBigIntU32(timestamp), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(0), + timestamp: PgBigIntU32(location.timestamp), } } - pub fn from_cenotaph_etching( - rune: &Rune, - number: u32, - 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_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(), @@ -130,11 +122,11 @@ 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), - timestamp: PgBigIntU32(timestamp), + total_burns: PgNumericU128(0), + total_operations: PgNumericU128(0), + timestamp: PgBigIntU32(location.timestamp), } } @@ -144,6 +136,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"), @@ -174,12 +167,71 @@ 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_location::TransactionLocation; + use super::DbRune; #[test] @@ -201,10 +253,16 @@ mod test { turbo: false, }, 0, - 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"); 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 44a35e8..8a9b200 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}; @@ -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; 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 + } +}