From 391a8c4d2cc194e00b286776a450e0bfda99c974 Mon Sep 17 00:00:00 2001 From: owl352 Date: Wed, 5 Aug 2026 18:23:35 +0300 Subject: [PATCH] initial commit --- api/docs/RPC.md | 82 +++++++++++++++++-- api/src/controllers/BlocksController.ts | 43 +++++++++- api/src/controllers/TransactionsController.ts | 9 ++ api/src/dao/BlocksDAO.ts | 47 ++++++++++- api/src/dao/TransactionsDAO.ts | 18 ++++ api/src/routes.ts | 33 +++++++- 6 files changed, 221 insertions(+), 11 deletions(-) diff --git a/api/docs/RPC.md b/api/docs/RPC.md index 8288422..8aefcb7 100644 --- a/api/docs/RPC.md +++ b/api/docs/RPC.md @@ -295,15 +295,55 @@ Returns a time series of the average transaction count per block over a configur --- -### GET /block/:hash +### GET /blocks/difficulty/chart -Returns a single block by its hash. +Returns a time series of the average mining difficulty over a configurable time range. + +**Query Parameters** + +| Parameter | Type | Default | Constraints | Description | +|-------------------|--------|-----------------------|----------------------|----------------------------------------------------------| +| `timestamp_start` | string | 1 hour ago (ISO 8601) | | Start of the time range | +| `timestamp_end` | string | now (ISO 8601) | | End of the time range | +| `intervals_count` | number | auto | minimum: 2, max: 100 | Number of buckets. When omitted, chosen automatically via `calculateInterval` | + +**Response `200`** + +```json +[ + { + "timestamp": "2026-07-12T06:00:00.000Z", + "data": { "avg": 103483713.33 } + }, + { + "timestamp": "2026-07-12T10:00:00.000Z", + "data": { "avg": 97596905.48 } + } +] +``` + +| Field | Type | Description | +|------------|--------|-------------------------------------------------------------------------| +| `timestamp`| string | ISO 8601 start of the bucket | +| `data.avg` | number | Average `difficulty` across all blocks in the bucket. `0` if no blocks fell in the bucket | + +**Response `400`** + +```json +{ "message": "start timestamp cannot be more than end timestamp" } +``` + +--- + +### GET /block/:identifier + +Returns a single block by its hash or height. **Path Parameters** -| Parameter | Type | Constraints | Description | -|-----------|--------|--------------------------------|------------------| -| `hash` | string | 64-char alphanumeric | Block hash | +| Parameter | Type | Constraints | Description | +|--------------|--------|------------------------------------------------------|----------------------| +| `identifier` | string | 64-char alphanumeric (hash) or 1–9 digits (height) | Block hash or height | **Response `200`** — [Block Object](#block-object) @@ -335,6 +375,38 @@ Returns a single block by its hash. --- +### GET /block/:hash/transactions + +Returns a paginated list of transaction hashes for the block with the given hash. Coinbase transaction first, then by insertion order. For full transaction objects use [`GET /transactions/height/:height`](#get-transactionsheightheight). + +**Path Parameters** + +| Parameter | Type | Constraints | Description | +|-----------|--------|----------------------|-------------| +| `hash` | string | 64-char alphanumeric | Block hash | + +**Query Parameters:** [Pagination](#pagination-query-parameters) + +**Response `200`** + +```json +{ + "resultSet": [ + "9d778417705481977761c27e835666d02da820e51505add96e64ebfc36f3e7d2", + "0eb957d2edee426a1848c6f9b9a2bedcc0db2df1c82ae9af54e435c129f50d8a" + ], + "pagination": { + "page": 1, + "limit": 10, + "total": 39 + } +} +``` + +> `total` comes from the block's `tx_count`. An unknown block hash yields an empty `resultSet` with `total: -1`. + +--- + ### GET /transactions Returns a paginated list of transactions. Include pending transactions diff --git a/api/src/controllers/BlocksController.ts b/api/src/controllers/BlocksController.ts index 77412ca..78d34b5 100644 --- a/api/src/controllers/BlocksController.ts +++ b/api/src/controllers/BlocksController.ts @@ -55,10 +55,47 @@ export default class BlocksController { response.send(series); } - getBlockByHash = async (request: FastifyRequest<{ Params: { hash: string } }>, response: FastifyReply): Promise => { - const { hash } = request.params; + getDifficultyStats = async ( + request: FastifyRequest<{ + Querystring: { timestamp_start: string; timestamp_end: string; intervals_count: number } + }>, + response: FastifyReply + ): Promise => { + const { + timestamp_start: start = new Date(new Date().getTime() - 3600000).toISOString(), + timestamp_end: end = new Date().toISOString(), + intervals_count: intervalsCount, + } = request.query; + + if (new Date(start).getTime() > new Date(end).getTime()) { + return response.status(400).send({ message: 'start timestamp cannot be more than end timestamp' }); + } + + const intervalInMs = + Math.ceil( + (new Date(end).getTime() - new Date(start).getTime()) / Number(intervalsCount ?? NaN) / 1000 + ) * 1000; + + const interval = intervalsCount + ? iso8601duration(intervalInMs) + : calculateInterval(new Date(start), new Date(end)); + + const series = await this.blocksDAO.getDifficultySeries( + new Date(start), + new Date(end), + interval, + isNaN(intervalInMs) ? Intervals[interval] : intervalInMs, + ); + + response.send(series); + } + + getBlockByHashOrHeight = async (request: FastifyRequest<{ Params: { identifier: string } }>, response: FastifyReply): Promise => { + const { identifier } = request.params; - const block = await this.blocksDAO.getBlockByHash(hash); + const block = identifier.length === 64 + ? await this.blocksDAO.getBlockByHash(identifier) + : await this.blocksDAO.getBlockByHeight(Number(identifier)); if (!block) { return response.status(404).send({ error: 'Block not found' }); diff --git a/api/src/controllers/TransactionsController.ts b/api/src/controllers/TransactionsController.ts index b8f8a0a..f7b59bf 100644 --- a/api/src/controllers/TransactionsController.ts +++ b/api/src/controllers/TransactionsController.ts @@ -51,6 +51,15 @@ export default class TransactionsController { response.send(transactions); }; + getBlockTransactionHashes = async (request: FastifyRequest<{ Querystring: PaginatedQuery; Params: { hash: string } }>, response: FastifyReply): Promise => { + const { page = 1, limit = 10, order = 'asc' } = request.query; + const { hash } = request.params; + + const hashes = await this.transactionsDAO.getTransactionHashesByBlockHash(hash, page, limit, order); + + response.send(hashes); + }; + getPendingTransactions = async (request: FastifyRequest<{ Querystring: PaginatedQuery }>, response: FastifyReply): Promise => { const { page = 1, limit = 10, order = 'asc' } = request.query; diff --git a/api/src/dao/BlocksDAO.ts b/api/src/dao/BlocksDAO.ts index dfb6377..14a879d 100644 --- a/api/src/dao/BlocksDAO.ts +++ b/api/src/dao/BlocksDAO.ts @@ -140,13 +140,58 @@ export default class BlocksDAO { }); }; + getDifficultySeries = async (start: Date, end: Date, interval: string, intervalInMs: number): Promise => { + const startSql = `'${new Date(start.getTime() + intervalInMs).toISOString()}'::timestamptz`; + const endSql = `'${new Date(end.getTime()).toISOString()}'::timestamptz`; + + const ranges = this.knex + .from(this.knex.raw(`generate_series(${startSql}, ${endSql}, '${interval}'::interval) date_to`)) + .select('date_to') + .select( + this.knex.raw( + 'LAG(date_to, 1, ?::timestamptz) OVER (ORDER BY date_to ASC) AS date_from', + [start.toISOString()] + ) + ); + + const bucketsCTE = this.knex('ranges') + .select('date_from') + .select(this.knex.raw('AVG(blocks.difficulty) AS avg_difficulty')) + .leftJoin('blocks', function () { + this.on('blocks.timestamp', '>', 'ranges.date_from') + .andOn('blocks.timestamp', '<=', 'ranges.date_to'); + }) + .groupBy('date_from'); + + const rows = await this.knex + .with('ranges', ranges) + .with('buckets', bucketsCTE) + .select('date_from') + .select(this.knex.raw('COALESCE(avg_difficulty, 0) AS avg_difficulty')) + .from('buckets') + .orderBy('date_from', 'asc'); + + return rows.map((row: any) => new SeriesData( + new Date(row.date_from), + { avg: row.avg_difficulty !== null ? parseFloat(parseFloat(row.avg_difficulty).toFixed(2)) : null }, + )); + } + getBlockByHash = async (hash: string): Promise => { + return this.getBlock({ 'blocks.hash': hash }); + }; + + getBlockByHeight = async (height: number): Promise => { + return this.getBlock({ 'blocks.height': height }); + }; + + private getBlock = async (where: Record): Promise => { const rows = await this.knex('blocks') .select('blocks.height', 'blocks.hash', 'blocks.difficulty', 'blocks.superblock', 'blocks.version', 'blocks.timestamp', 'blocks.tx_count', 'blocks.size', 'blocks.nonce', 'blocks.previous_block_hash', 'blocks.merkle_root', 'blocks.credit_pool_balance') .select(this.knex.raw('(SELECT MAX(height) FROM blocks) - blocks.height + 1 AS confirmations')) - .where('blocks.hash', hash) + .where(where) .limit(1) const [row] = rows; diff --git a/api/src/dao/TransactionsDAO.ts b/api/src/dao/TransactionsDAO.ts index 6705e1e..35c610d 100644 --- a/api/src/dao/TransactionsDAO.ts +++ b/api/src/dao/TransactionsDAO.ts @@ -278,6 +278,24 @@ export default class TransactionsDAO { return new PaginatedResultSet(rows.map(Transaction.fromRow), page, limit, row?.total_count); }; + getTransactionHashesByBlockHash = async (blockHash: string, page: number, limit: number, order: string): Promise> => { + const fromRank = (page - 1) * limit; + + const rows = await this.knex('transactions') + .select('transactions.hash') + .select('blocks.tx_count as total_count') + .join('blocks', 'blocks.height', 'transactions.block_height') + .where('blocks.hash', blockHash) + .orderBy('transactions.is_coinbase', 'desc') + .orderBy('transactions.id', order) + .limit(limit) + .offset(fromRank) + + const [row] = rows; + + return new PaginatedResultSet(rows.map((row) => row.hash), page, limit, row?.total_count); + }; + getPendingTransactions = async (page: number, limit: number, order: string): Promise> => { const fromRank = (page - 1) * limit; diff --git a/api/src/routes.ts b/api/src/routes.ts index fe57b16..c61e8d8 100644 --- a/api/src/routes.ts +++ b/api/src/routes.ts @@ -46,15 +46,44 @@ export default function Routes({ fastify, mainController, blocksController, tran }, }, { - path: '/block/:hash', + path: '/blocks/difficulty/chart', method: 'get', - handler: blocksController.getBlockByHash, + handler: blocksController.getDifficultyStats, + schema: { + querystring: { $ref: 'timeInterval#' }, + }, + }, + { + path: '/block/:identifier', + method: 'get', + handler: blocksController.getBlockByHashOrHeight, schema: { + params: { + type: 'object', + properties: { + identifier: { + oneOf: [ + { $ref: 'hash#' }, + { type: 'string', pattern: '^[0-9]{1,9}$' }, + ], + }, + }, + required: ['identifier'], + }, + }, + }, + { + path: '/block/:hash/transactions', + method: 'get', + handler: transactionsController.getBlockTransactionHashes, + schema: { + querystring: { $ref: 'paginationOptions#' }, params: { type: 'object', properties: { hash: { $ref: 'hash#' }, }, + required: ['hash'], }, }, },