diff --git a/webserver/server/app/controllers/MintBurnHistoryController.ts b/webserver/server/app/controllers/MintBurnHistoryController.ts new file mode 100644 index 00000000..affa8d08 --- /dev/null +++ b/webserver/server/app/controllers/MintBurnHistoryController.ts @@ -0,0 +1,151 @@ +import { Body, Controller, TsoaResponse, Res, Post, Route, SuccessResponse } from 'tsoa'; +import { StatusCodes } from 'http-status-codes'; +import tx from 'pg-tx'; +import pool from '../services/PgPoolSingleton'; + +import type { ErrorShape } from '../../../shared/errors'; +import type { EndpointTypes } from '../../../shared/routes'; +import { Routes } from '../../../shared/routes'; +import { mintBurnRange, mintBurnRangeByPolicyIds } from '../services/MintBurnHistoryService'; +import type { MintBurnHistoryRequest, MintBurnHistoryResponse, MintBurnSingleResponse } from "../../../shared/models/MintBurn"; +import type { PolicyId } from "../../../shared/models/PolicyIdAssetMap"; +import type { ISqlMintBurnRangeResult, ISqlMintBurnRangeByPolicyIdsResult } from "../models/asset/mintBurnHistory.queries"; + +const route = Routes.mintBurnHistory; + +@Route('asset/mint-burn-history') +export class MintRangeController extends Controller { + @SuccessResponse(`${StatusCodes.OK}`) + @Post() + public async projectedNftRange( + @Body() + requestBody: EndpointTypes[typeof route]['input'], + @Res() + errorResponse: TsoaResponse< + StatusCodes.BAD_REQUEST | StatusCodes.CONFLICT | StatusCodes.UNPROCESSABLE_ENTITY, + ErrorShape + > + ): Promise { + if (requestBody.policyIds !== undefined && requestBody.policyIds.length > 0) { + return await this.handle_by_policy_ids_query(requestBody.policyIds, requestBody); + } else { + return await this.handle_general_query(requestBody); + } + } + + async handle_general_query( + requestBody: EndpointTypes[typeof route]['input'], + ): Promise { + const assets = await tx< + ISqlMintBurnRangeResult[] + >(pool, async dbTx => { + const data = await mintBurnRange({ + range: requestBody.range, + dbTx + }); + + return data; + }); + + let mintRangeResponse: MintBurnSingleResponse = { + actionTxId: "", + actionBlockId: "", + metadata: null, + actionSlot: 0, + assets: {}, + }; + + const result: MintBurnSingleResponse[] = []; + + for (const entry of assets) { + const policyId = entry.policy_id !== null ? entry.policy_id.toString() : ""; + const assetName = entry.asset_name !== null ? entry.asset_name.toString() : ""; + const actionTxId = entry.action_tx_id !== null ? entry.action_tx_id.toString() : ""; + const actionBlockId = entry.action_block_id !== null ? entry.action_block_id.toString() : ""; + + if (mintRangeResponse.actionTxId != actionTxId) { + if (mintRangeResponse.actionTxId.length > 0) { + result.push(mintRangeResponse); + } + + mintRangeResponse = { + actionSlot: entry.action_slot, + actionTxId: actionTxId, + actionBlockId: actionBlockId, + metadata: entry.action_tx_metadata, + assets: {}, + } + } + + const for_policy = mintRangeResponse.assets[policyId] ?? {}; + + for_policy[assetName] = entry.amount; + mintRangeResponse.assets[policyId] = for_policy; + } + + if (mintRangeResponse.actionTxId.length > 0) { + result.push(mintRangeResponse); + } + + return result; + } + + async handle_by_policy_ids_query( + policyIds: PolicyId[], + requestBody: EndpointTypes[typeof route]['input'], + ): Promise { + const assets = await tx< + ISqlMintBurnRangeByPolicyIdsResult[] + >(pool, async dbTx => { + const data = await mintBurnRangeByPolicyIds({ + range: requestBody.range, + policyIds: policyIds, + dbTx + }); + + return data; + }); + + let mintRangeResponse: MintBurnSingleResponse = { + actionTxId: "", + actionBlockId: "", + metadata: null, + actionSlot: 0, + assets: {}, + }; + + const result: MintBurnSingleResponse[] = []; + + for (const entry of assets) { + const policyId = entry.policy_id !== null ? entry.policy_id.toString() : ""; + const assetName = entry.asset_name !== null ? entry.asset_name.toString() : ""; + const actionTxId = entry.action_tx_id !== null ? entry.action_tx_id.toString() : ""; + const actionBlockId = entry.action_block_id !== null ? entry.action_block_id.toString() : ""; + + if (mintRangeResponse.actionTxId != actionTxId) { + if (mintRangeResponse.actionTxId.length > 0) { + result.push(mintRangeResponse); + } + + mintRangeResponse = { + actionSlot: entry.action_slot, + actionTxId: actionTxId, + actionBlockId: actionBlockId, + metadata: entry.action_tx_metadata, + assets: {}, + } + } + + const for_policy = mintRangeResponse.assets[policyId] ?? {}; + + for_policy[assetName] = entry.amount; + mintRangeResponse.assets[policyId] = for_policy; + } + + if (mintRangeResponse.actionTxId.length > 0) { + result.push(mintRangeResponse); + } + + return result; + } +} \ No newline at end of file diff --git a/webserver/server/app/models/asset/mintBurnHistory.queries.ts b/webserver/server/app/models/asset/mintBurnHistory.queries.ts new file mode 100644 index 00000000..365cc555 --- /dev/null +++ b/webserver/server/app/models/asset/mintBurnHistory.queries.ts @@ -0,0 +1,111 @@ +/** Types generated for queries found in "app/models/asset/mintBurnHistory.sql" */ +import { PreparedQuery } from '@pgtyped/runtime'; + +/** 'SqlMintBurnRange' parameters type */ +export interface ISqlMintBurnRangeParams { + max_slot: number; + min_slot: number; +} + +/** 'SqlMintBurnRange' return type */ +export interface ISqlMintBurnRangeResult { + action_block_id: string | null; + action_slot: number; + action_tx_id: string | null; + action_tx_metadata: string | null; + amount: string; + asset_name: string | null; + policy_id: string | null; +} + +/** 'SqlMintBurnRange' query type */ +export interface ISqlMintBurnRangeQuery { + params: ISqlMintBurnRangeParams; + result: ISqlMintBurnRangeResult; +} + +const sqlMintBurnRangeIR: any = {"usedParamSet":{"min_slot":true,"max_slot":true},"params":[{"name":"min_slot","required":true,"transform":{"type":"scalar"},"locs":[{"a":793,"b":802}]},{"name":"max_slot","required":true,"transform":{"type":"scalar"},"locs":[{"a":828,"b":837}]}],"statement":"SELECT\n \"AssetMint\".amount as amount,\n encode(\"NativeAsset\".policy_id, 'hex') as policy_id,\n encode(\"NativeAsset\".asset_name, 'hex') as asset_name,\n encode(\"Transaction\".hash, 'hex') as action_tx_id,\n encode(\"Block\".hash, 'hex') as action_block_id,\n CASE\n WHEN \"TransactionMetadata\".payload = NULL THEN NULL\n ELSE encode(\"TransactionMetadata\".payload, 'hex')\n END AS action_tx_metadata,\n \"Block\".slot as action_slot\nFROM \"AssetMint\"\n LEFT JOIN \"TransactionMetadata\" ON \"TransactionMetadata\".id = \"AssetMint\".tx_id\n JOIN \"NativeAsset\" ON \"NativeAsset\".id = \"AssetMint\".asset_id\n JOIN \"Transaction\" ON \"Transaction\".id = \"AssetMint\".tx_id\n JOIN \"Block\" ON \"Transaction\".block_id = \"Block\".id\nWHERE\n \"Block\".slot > :min_slot!\n AND \"Block\".slot <= :max_slot!\nORDER BY (\"Block\".height, \"Transaction\".tx_index) ASC"}; + +/** + * Query generated from SQL: + * ``` + * SELECT + * "AssetMint".amount as amount, + * encode("NativeAsset".policy_id, 'hex') as policy_id, + * encode("NativeAsset".asset_name, 'hex') as asset_name, + * encode("Transaction".hash, 'hex') as action_tx_id, + * encode("Block".hash, 'hex') as action_block_id, + * CASE + * WHEN "TransactionMetadata".payload = NULL THEN NULL + * ELSE encode("TransactionMetadata".payload, 'hex') + * END AS action_tx_metadata, + * "Block".slot as action_slot + * FROM "AssetMint" + * LEFT JOIN "TransactionMetadata" ON "TransactionMetadata".id = "AssetMint".tx_id + * JOIN "NativeAsset" ON "NativeAsset".id = "AssetMint".asset_id + * JOIN "Transaction" ON "Transaction".id = "AssetMint".tx_id + * JOIN "Block" ON "Transaction".block_id = "Block".id + * WHERE + * "Block".slot > :min_slot! + * AND "Block".slot <= :max_slot! + * ORDER BY ("Block".height, "Transaction".tx_index) ASC + * ``` + */ +export const sqlMintBurnRange = new PreparedQuery(sqlMintBurnRangeIR); + + +/** 'SqlMintBurnRangeByPolicyIds' parameters type */ +export interface ISqlMintBurnRangeByPolicyIdsParams { + max_slot: number; + min_slot: number; + policy_ids: readonly (Buffer)[]; +} + +/** 'SqlMintBurnRangeByPolicyIds' return type */ +export interface ISqlMintBurnRangeByPolicyIdsResult { + action_block_id: string | null; + action_slot: number; + action_tx_id: string | null; + action_tx_metadata: string | null; + amount: string; + asset_name: string | null; + policy_id: string | null; +} + +/** 'SqlMintBurnRangeByPolicyIds' query type */ +export interface ISqlMintBurnRangeByPolicyIdsQuery { + params: ISqlMintBurnRangeByPolicyIdsParams; + result: ISqlMintBurnRangeByPolicyIdsResult; +} + +const sqlMintBurnRangeByPolicyIdsIR: any = {"usedParamSet":{"min_slot":true,"max_slot":true,"policy_ids":true},"params":[{"name":"policy_ids","required":true,"transform":{"type":"array_spread"},"locs":[{"a":874,"b":885}]},{"name":"min_slot","required":true,"transform":{"type":"scalar"},"locs":[{"a":793,"b":802}]},{"name":"max_slot","required":true,"transform":{"type":"scalar"},"locs":[{"a":828,"b":837}]}],"statement":"SELECT\n \"AssetMint\".amount as amount,\n encode(\"NativeAsset\".policy_id, 'hex') as policy_id,\n encode(\"NativeAsset\".asset_name, 'hex') as asset_name,\n encode(\"Transaction\".hash, 'hex') as action_tx_id,\n encode(\"Block\".hash, 'hex') as action_block_id,\n CASE\n WHEN \"TransactionMetadata\".payload = NULL THEN NULL\n ELSE encode(\"TransactionMetadata\".payload, 'hex')\n END AS action_tx_metadata,\n \"Block\".slot as action_slot\nFROM \"AssetMint\"\n LEFT JOIN \"TransactionMetadata\" ON \"TransactionMetadata\".id = \"AssetMint\".tx_id\n JOIN \"NativeAsset\" ON \"NativeAsset\".id = \"AssetMint\".asset_id\n JOIN \"Transaction\" ON \"Transaction\".id = \"AssetMint\".tx_id\n JOIN \"Block\" ON \"Transaction\".block_id = \"Block\".id\nWHERE\n \"Block\".slot > :min_slot!\n AND \"Block\".slot <= :max_slot!\n AND \"NativeAsset\".policy_id IN :policy_ids!\nORDER BY (\"Block\".height, \"Transaction\".tx_index) ASC"}; + +/** + * Query generated from SQL: + * ``` + * SELECT + * "AssetMint".amount as amount, + * encode("NativeAsset".policy_id, 'hex') as policy_id, + * encode("NativeAsset".asset_name, 'hex') as asset_name, + * encode("Transaction".hash, 'hex') as action_tx_id, + * encode("Block".hash, 'hex') as action_block_id, + * CASE + * WHEN "TransactionMetadata".payload = NULL THEN NULL + * ELSE encode("TransactionMetadata".payload, 'hex') + * END AS action_tx_metadata, + * "Block".slot as action_slot + * FROM "AssetMint" + * LEFT JOIN "TransactionMetadata" ON "TransactionMetadata".id = "AssetMint".tx_id + * JOIN "NativeAsset" ON "NativeAsset".id = "AssetMint".asset_id + * JOIN "Transaction" ON "Transaction".id = "AssetMint".tx_id + * JOIN "Block" ON "Transaction".block_id = "Block".id + * WHERE + * "Block".slot > :min_slot! + * AND "Block".slot <= :max_slot! + * AND "NativeAsset".policy_id IN :policy_ids! + * ORDER BY ("Block".height, "Transaction".tx_index) ASC + * ``` + */ +export const sqlMintBurnRangeByPolicyIds = new PreparedQuery(sqlMintBurnRangeByPolicyIdsIR); + + diff --git a/webserver/server/app/models/asset/mintBurnHistory.sql b/webserver/server/app/models/asset/mintBurnHistory.sql new file mode 100644 index 00000000..56a724bc --- /dev/null +++ b/webserver/server/app/models/asset/mintBurnHistory.sql @@ -0,0 +1,49 @@ +/* +@name sqlMintBurnRange +*/ +SELECT + "AssetMint".amount as amount, + encode("NativeAsset".policy_id, 'hex') as policy_id, + encode("NativeAsset".asset_name, 'hex') as asset_name, + encode("Transaction".hash, 'hex') as action_tx_id, + encode("Block".hash, 'hex') as action_block_id, + CASE + WHEN "TransactionMetadata".payload = NULL THEN NULL + ELSE encode("TransactionMetadata".payload, 'hex') + END AS action_tx_metadata, + "Block".slot as action_slot +FROM "AssetMint" + LEFT JOIN "TransactionMetadata" ON "TransactionMetadata".id = "AssetMint".tx_id + JOIN "NativeAsset" ON "NativeAsset".id = "AssetMint".asset_id + JOIN "Transaction" ON "Transaction".id = "AssetMint".tx_id + JOIN "Block" ON "Transaction".block_id = "Block".id +WHERE + "Block".slot > :min_slot! + AND "Block".slot <= :max_slot! +ORDER BY ("Block".height, "Transaction".tx_index) ASC; + +/* +@name sqlMintBurnRangeByPolicyIds +@param policy_ids -> (...) +*/ +SELECT + "AssetMint".amount as amount, + encode("NativeAsset".policy_id, 'hex') as policy_id, + encode("NativeAsset".asset_name, 'hex') as asset_name, + encode("Transaction".hash, 'hex') as action_tx_id, + encode("Block".hash, 'hex') as action_block_id, + CASE + WHEN "TransactionMetadata".payload = NULL THEN NULL + ELSE encode("TransactionMetadata".payload, 'hex') + END AS action_tx_metadata, + "Block".slot as action_slot +FROM "AssetMint" + LEFT JOIN "TransactionMetadata" ON "TransactionMetadata".id = "AssetMint".tx_id + JOIN "NativeAsset" ON "NativeAsset".id = "AssetMint".asset_id + JOIN "Transaction" ON "Transaction".id = "AssetMint".tx_id + JOIN "Block" ON "Transaction".block_id = "Block".id +WHERE + "Block".slot > :min_slot! + AND "Block".slot <= :max_slot! + AND "NativeAsset".policy_id IN :policy_ids! +ORDER BY ("Block".height, "Transaction".tx_index) ASC; diff --git a/webserver/server/app/services/MintBurnHistoryService.ts b/webserver/server/app/services/MintBurnHistoryService.ts new file mode 100644 index 00000000..8c108e36 --- /dev/null +++ b/webserver/server/app/services/MintBurnHistoryService.ts @@ -0,0 +1,26 @@ +import type { PoolClient } from 'pg'; +import type { ISqlMintBurnRangeResult, ISqlMintBurnRangeByPolicyIdsResult } from '../models/asset/mintBurnHistory.queries'; +import { sqlMintBurnRange, sqlMintBurnRangeByPolicyIds } from '../models/asset/mintBurnHistory.queries'; +import type { PolicyId } from "../../../shared/models/PolicyIdAssetMap"; + +export async function mintBurnRange(request: { + range: { minSlot: number, maxSlot: number }, + dbTx: PoolClient, +}): Promise { + return (await sqlMintBurnRange.run({ + min_slot: request.range.minSlot, + max_slot: request.range.maxSlot, + }, request.dbTx)); +} + +export async function mintBurnRangeByPolicyIds(request: { + range: { minSlot: number, maxSlot: number }, + policyIds: PolicyId[], + dbTx: PoolClient, +}): Promise { + return (await sqlMintBurnRangeByPolicyIds.run({ + min_slot: request.range.minSlot, + max_slot: request.range.maxSlot, + policy_ids: request.policyIds.map(id => Buffer.from(id, 'hex')), + }, request.dbTx)); +} diff --git a/webserver/shared/models/MintBurn.ts b/webserver/shared/models/MintBurn.ts new file mode 100644 index 00000000..8a442201 --- /dev/null +++ b/webserver/shared/models/MintBurn.ts @@ -0,0 +1,62 @@ +import {PolicyId} from "./PolicyIdAssetMap" +import {Amount} from "./common"; + +export type MintBurnHistoryRequest = { + /** + * Mint Burn events in this slot range will be returned + */ + range: { + /** + * Minimal slot from which the events should be returned (not inclusive) + * + * @example 46154769 + */ + minSlot: number, + /** + * Maximal slot from which the events should be returned (inclusive) + * + * @example 46154860 + */ + maxSlot: number + }, + policyIds: PolicyId[] | undefined +}; + +export type MintBurnSingleResponse = { + /** + * Slot at which the transaction happened + * + * @example 512345 + */ + actionSlot: number, + + /** + * Transaction id of related mint / burn event + * + * @pattern [0-9a-fA-F]{64} + * @example "28eb069e3e8c13831d431e3b2e35f58525493ab2d77fde83184993e4aa7a0eda" + */ + actionTxId: string, + + /** + * Block id of related mint / burn event + * + * @pattern [0-9a-fA-F]{64} + * @example "4e90f1d14ad742a1c0e094a89ad180b896068f93fc3969614b1c53bac547b374" + */ + actionBlockId: string, + + /** + * Transaction metadata of related mint / burn event + */ + metadata: string | null, + + /** + * Assets changed in a particular transaction + * + * @example { "b863bc7369f46136ac1048adb2fa7dae3af944c3bbb2be2f216a8d4f": { "42657272794e617679": "1" }} + */ + assets: { [policyId: string]: { [assetName: string]: Amount } }; +}; + +export type MintBurnHistoryResponse = MintBurnSingleResponse[] \ No newline at end of file diff --git a/webserver/shared/routes.ts b/webserver/shared/routes.ts index 904d2542..9088b507 100644 --- a/webserver/shared/routes.ts +++ b/webserver/shared/routes.ts @@ -32,6 +32,10 @@ import type { ProjectedNftRangeResponse, } from "./models/ProjectedNftRange"; import { AssetUtxosRequest, AssetUtxosResponse } from "./models/AssetUtxos"; +import type { + MintBurnHistoryRequest, + MintBurnHistoryResponse, +} from "./models/MintBurn"; export enum Routes { transactionHistory = "transaction/history", @@ -47,6 +51,7 @@ export enum Routes { delegationForPool = "delegation/pool", projectedNftEventsRange = "projected-nft/range", assetUtxos = "asset/utxos", + mintBurnHistory = "asset/mint-burn-history", } export type EndpointTypes = { @@ -115,4 +120,9 @@ export type EndpointTypes = { input: AssetUtxosRequest; response: AssetUtxosResponse; }; + [Routes.mintBurnHistory]: { + name: typeof Routes.mintBurnHistory; + input: MintBurnHistoryRequest; + response: MintBurnHistoryResponse; + }; };