From a382d2b80fc8d3e7ff49ce96047f1621749172b2 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Thu, 15 Aug 2024 15:17:57 +0200 Subject: [PATCH] fix: pagination and query param parsing bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: route splitting between express and fastify * feat: refactor `/extended/v1/tx/*` endpoints to fastify * feat: refactor `/extended/v1/stx_supply/*` endpoints to fastify * feat: refactor `/extended/v1/info/*` endpoints to fastify * feat: refactor `/extended/v1/tokens/*` endpoints to fastify * feat: refactor `/extended/v1/tokens/*` endpoints to fastify * feat: refactor `/extended/v1/contract/*` endpoints to fastify * feat: refactor `/extended/v1/fee_rate/*` endpoints to fastify * feat: refactor `/extended/v1/microblock/*` endpoints to fastify * feat: refactor `/extended/v1/block/*` endpoints to fastify * feat: refactor `/extended/v1/burnchain/*` endpoints to fastify * feat: refactor `/extended/v1/address/*` endpoints to fastify * feat: refactor `/extended/v1/search/*` endpoints to fastify * feat: refactor `/extended/v1/pox*` endpoints to fastify * feat: refactor `/extended/v1/faucets/*` endpoints to fastify * feat: refactor `/extended/v1/debug/*` endpoints to fastify * feat: refactor `/extended/v2/blocks/*` and `/extended/v2/burn-blocks/*` endpoints to fastify * feat: refactor `/extended/v2/smart-contracts/*` endpoints to fastify * feat: refactor `/extended/v2/mempool/*` endpoints to fastify * feat: refactor `/extended/v2/pox/*` endpoints to fastify * feat: refactor `/extended/v2/addresses/*` endpoints to fastify * feat: refactor `/v1/names/*` endpoints to fastify * feat: refactor `/v1/namespaces/*` endpoints to fastify * feat: refactor `/v1/addresses/*` endpoints to fastify * feat: refactor `/v2/prices/*` endpoints to fastify * feat: refactor core-node RPC proxy (/v2/*) to fastify * chore: remove dead code * feat: openAPI spec generation from fastify routes * chore: remove references to legacy generated types * docs: missing openapi tag on burn-blocks route * docs: update docs and client generation scripts * fix: several query params should be optional * fix: only use a GET route for extended status * chore: simpify typing for TransactionSchema and MempoolTransactionSchema * feat: refactor client library * chore: remove dependencies on old generated types * chore: isolate rosetta json schemas and delete the rest * chore: cleanup prometheus metrics * fix: misc tests * test: misc rosetta fixes * fix: batch insert length assertion (#2042) * fix: batch insert length assertion * build: upgrade docker-compose * build: use docker compose * test: misc bns test fixes * test: misc pox fixes * ci: misc fixes * chore: fix unused exports lint * chore: simplify docs and client package.json scripts * feat: refactor event-server from express to fastify * chore: expose more helper types in the client lib * ci: fix client npm lib building * fix: openapi and client support for comma-separated query params * chore: expose more helper types to client lib * ci: fix lint and tests * fix: ensure height-or-hash params are parsed correctly and have correct openapi and client types * docs: client library migration guide * fix: tx event pagination limit * docs: note RPC client library change --------- Co-authored-by: Rafael Cárdenas --- client/MIGRATION.md | 73 +++++++ client/README.md | 4 + client/src/generated/schema.d.ts | 14 +- client/src/index.ts | 1 + client/src/types.d.ts | 31 ++- docs/openapi.json | 183 +++++++----------- docs/openapi.yaml | 127 +++++------- src/api/routes/tx.ts | 61 +++--- src/api/routes/v2/blocks.ts | 10 +- src/api/routes/v2/burn-blocks.ts | 10 +- src/api/routes/v2/schemas.ts | 43 ++-- .../schemas/entities/transaction-events.ts | 5 - src/api/schemas/entities/transactions.ts | 7 +- src/api/schemas/params.ts | 9 - src/api/schemas/util.ts | 21 +- src/datastore/pg-store-v2.ts | 2 - src/test-utils/test-builders.ts | 31 ++- src/tests/block-tests.ts | 21 ++ src/tests/tx-tests.ts | 81 +++++++- 19 files changed, 421 insertions(+), 313 deletions(-) create mode 100644 client/MIGRATION.md diff --git a/client/MIGRATION.md b/client/MIGRATION.md new file mode 100644 index 000000000..013fe3a5f --- /dev/null +++ b/client/MIGRATION.md @@ -0,0 +1,73 @@ +## @stacks/blockchain-api-client (<=7.x.x) → (8.x.x) + +## Breaking Changes + +This library is now generated with [openapi-typescript](https://openapi-ts.dev/openapi-fetch/) rather than [swagger-codegen](https://github.com/swagger-api/swagger-codegen). Several types which previously presented as the `any` type are now fixed, and the `@stacks/stacks-blockchain-api-types` package is no longer needed. + + +This repo no longer includes a schema for the Stacks Blockchain RPC interface. An alternative client library for the RPC interface can be found at https://github.com/hirosystems/stacks.js/pull/1737. + +#### Configuration & Middleware + +```ts +// old: +import { TransactionsApi, Configuration } from '@stacks/blockchain-api-client'; +const client = new TransactionsApi(new Configuration({ + basePath: 'https://api.mainnet.hiro.so', + middleware: [{ + pre({url, init}) { + init.headers = new Headers(init.headers); + init.headers.set('x-custom-header', 'custom-value'); + return Promise.resolve({ url, init }); + } + }] +})); + + +// new: +import { createClient } from '@stacks/blockchain-api-client'; +const client = createClient({ + baseUrl: 'https://api.mainnet.hiro.so' +}); +client.use({ + onRequest({request}) { + request.headers.set('x-custom-header', 'custom-value'); + return request; + } +}); +``` + +#### Performing Requests + +```ts +// old: +const blockTxs = await client.getTransactionsByBlock({ + heightOrHash: 2000, + limit: 20, + offset: 100 +}); +console.log('Block transactions:', blockTxs); + +// new: +const { data: blockTxs } = await client.GET('/extended/v2/blocks/{height_or_hash}/transactions', { + params: { + path: { height_or_hash: 2000 }, + query: { limit: 20, offset: 100 }, + } +}); +console.log('Block transactions:', blockTxs); +``` + +#### Referencing Types + +```ts +// old: +import { MempoolTransactionStatsResponse } from '@stacks/blockchain-api-client'; +let response: MempoolTransactionStatsResponse; +response = await client.getMempoolTransactionStats(); + +// new: +import { OperationResponse } from '@stacks/blockchain-api-client'; +let response: OperationResponse['/extended/v1/tx/mempool/stats']; +response = (await client.GET('/extended/v1/tx/mempool/stats')).data; +``` diff --git a/client/README.md b/client/README.md index 899c5ef2c..da9522218 100644 --- a/client/README.md +++ b/client/README.md @@ -3,6 +3,10 @@ A JS Client for the Stacks Blockchain API +## Breaking changes from (<=7.x.x) → (8.x.x) + +See [MIGRATION.md](./MIGRATION.md) for details. + ## Features This package provides the ability to: diff --git a/client/src/generated/schema.d.ts b/client/src/generated/schema.d.ts index 9ad324d3f..dde513f08 100644 --- a/client/src/generated/schema.d.ts +++ b/client/src/generated/schema.d.ts @@ -1812,7 +1812,7 @@ export interface operations { offset?: number; /** @description Results per page */ limit?: number; - type?: (("coinbase" | "token_transfer" | "smart_contract" | "contract_call" | "poison_microblock" | "tenure_change") | string)[]; + type?: ("coinbase" | "token_transfer" | "smart_contract" | "contract_call" | "poison_microblock" | "tenure_change")[]; /** * @description Include data from unanchored (i.e. unconfirmed) microblocks * @example true @@ -3177,7 +3177,7 @@ export interface operations { get_tx_list_details: { parameters: { query: { - tx_id: (string)[]; + tx_id: string[]; /** @description Results per page */ event_limit?: number; /** @description Result offset */ @@ -6496,7 +6496,7 @@ export interface operations { */ tx_id?: string; address?: string; - type?: (("smart_contract_log" | "stx_lock" | "stx_asset" | "fungible_token_asset" | "non_fungible_token_asset") | string)[]; + type?: ("smart_contract_log" | "stx_lock" | "stx_asset" | "fungible_token_asset" | "non_fungible_token_asset")[]; /** @description Result offset */ offset?: number; /** @description Results per page */ @@ -27255,7 +27255,7 @@ export interface operations { query?: never; header?: never; path: { - height_or_hash: "latest" | string; + height_or_hash: "latest" | string | number; }; cookie?: never; }; @@ -27321,7 +27321,7 @@ export interface operations { }; header?: never; path: { - height_or_hash: "latest" | string; + height_or_hash: "latest" | string | number; }; cookie?: never; }; @@ -28697,7 +28697,7 @@ export interface operations { query?: never; header?: never; path: { - height_or_hash: "latest" | string; + height_or_hash: "latest" | string | number; }; cookie?: never; }; @@ -28739,7 +28739,7 @@ export interface operations { }; header?: never; path: { - height_or_hash: "latest" | string; + height_or_hash: "latest" | string | number; }; cookie?: never; }; diff --git a/client/src/index.ts b/client/src/index.ts index 347f47a81..6c9b99e04 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -10,3 +10,4 @@ export * from './common'; export * from './socket-io'; export * from './ws'; export type * from './types'; +export * from 'openapi-fetch'; diff --git a/client/src/types.d.ts b/client/src/types.d.ts index 37e877344..366a54be3 100644 --- a/client/src/types.d.ts +++ b/client/src/types.d.ts @@ -1,14 +1,25 @@ -import type { operations } from "./generated/schema"; +import type { operations, paths } from './generated/schema'; + +type Extract200Response = T extends { 200: infer R } ? R : never; +type ExtractOperationResponse = Extract200Response extends { content: { 'application/json': infer U } } ? U : never; +type PathResponse = paths[T]['get'] extends { responses: infer R } ? Extract200Response extends { content: { 'application/json': infer U } } ? U : never : never; + +export type OperationResponse = { + [K in keyof operations]: ExtractOperationResponse; +} & { + [P in keyof paths]: PathResponse

; +}; + +export type Transaction = OperationResponse['get_transaction_list']['results'][number]; +export type MempoolTransaction = OperationResponse['get_mempool_transaction_list']['results'][number]; +export type Block = OperationResponse['get_block_by_height']; +export type Microblock = OperationResponse['get_microblock_by_hash']; +export type NakamotoBlock = OperationResponse['get_block']; +export type BurnBlock = OperationResponse['get_burn_blocks']['results'][number]; +export type SmartContract = OperationResponse['get_contract_by_id']; +export type AddressTransactionWithTransfers = OperationResponse['get_account_transactions_with_transfers']['results'][number]; +export type AddressStxBalanceResponse = OperationResponse['get_account_stx_balance']; -export type Transaction = operations['get_transaction_list']['responses']['200']['content']['application/json']['results'][number]; -export type MempoolTransaction = operations['get_mempool_transaction_list']['responses']['200']['content']['application/json']['results'][number]; -export type Block = operations['get_block_by_height']['responses']['200']['content']['application/json']; -export type Microblock = operations['get_microblock_by_hash']['responses']['200']['content']['application/json']; -export type NakamotoBlock = operations['get_block']['responses']['200']['content']['application/json']; -export type BurnBlock = operations['get_burn_blocks']['responses']['200']['content']['application/json']['results'][number]; -export type SmartContract = operations['get_contract_by_id']['responses']['200']['content']['application/json']; -export type AddressTransactionWithTransfers = operations['get_account_transactions_with_transfers']['responses']['200']['content']['application/json']['results'][number]; -export type AddressStxBalanceResponse = operations['get_account_stx_balance']['responses']['200']['content']['application/json']; export type RpcAddressTxNotificationParams = AddressTransactionWithTransfers & { address: string; tx_id: string; diff --git a/docs/openapi.json b/docs/openapi.json index b1f95ec32..c7ff6ee8c 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -158,52 +158,40 @@ "items": { "anyOf": [ { - "anyOf": [ - { - "type": "string", - "enum": [ - "coinbase" - ] - }, - { - "type": "string", - "enum": [ - "token_transfer" - ] - }, - { - "type": "string", - "enum": [ - "smart_contract" - ] - }, - { - "type": "string", - "enum": [ - "contract_call" - ] - }, - { - "type": "string", - "enum": [ - "poison_microblock" - ] - }, - { - "type": "string", - "enum": [ - "tenure_change" - ] - } + "type": "string", + "enum": [ + "coinbase" ] }, { - "pattern": "^(coinbase|token_transfer|smart_contract|contract_call|poison_microblock|tenure_change)(,(coinbase|token_transfer|smart_contract|contract_call|poison_microblock|tenure_change))*$", - "description": "Comma separated list of transaction types", - "examples": [ - "coinbase,token_transfer,smart_contract" - ], - "type": "string" + "type": "string", + "enum": [ + "token_transfer" + ] + }, + { + "type": "string", + "enum": [ + "smart_contract" + ] + }, + { + "type": "string", + "enum": [ + "contract_call" + ] + }, + { + "type": "string", + "enum": [ + "poison_microblock" + ] + }, + { + "type": "string", + "enum": [ + "tenure_change" + ] } ] } @@ -6695,26 +6683,13 @@ "schema": { "type": "array", "items": { - "anyOf": [ - { - "pattern": "^(0x)?[a-fA-F0-9]{64}$", - "title": "Transaction ID", - "description": "Transaction ID", - "examples": [ - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6" - ], - "type": "string" - }, - { - "pattern": "^(0x)?[a-fA-F0-9]{64}(,(0x)?[a-fA-F0-9]{64})*$", - "title": "Comma separated list of transaction IDs", - "description": "Comma separate list of transaction IDs", - "examples": [ - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6,0xbf06fc00be4333166b9a9be0557b9f560bee8700dfe7988bd3d3df1f1a077ed8" - ], - "type": "string" - } - ] + "pattern": "^(0x)?[a-fA-F0-9]{64}$", + "title": "Transaction ID", + "description": "Transaction ID", + "examples": [ + "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6" + ], + "type": "string" } }, "in": "query", @@ -22577,46 +22552,34 @@ "items": { "anyOf": [ { - "anyOf": [ - { - "type": "string", - "enum": [ - "smart_contract_log" - ] - }, - { - "type": "string", - "enum": [ - "stx_lock" - ] - }, - { - "type": "string", - "enum": [ - "stx_asset" - ] - }, - { - "type": "string", - "enum": [ - "fungible_token_asset" - ] - }, - { - "type": "string", - "enum": [ - "non_fungible_token_asset" - ] - } + "type": "string", + "enum": [ + "smart_contract_log" ] }, { - "pattern": "^(smart_contract_log|stx_lock|stx_asset|fungible_token_asset|non_fungible_token_asset)(,(smart_contract_log|stx_lock|stx_asset|fungible_token_asset|non_fungible_token_asset))*$", - "description": "Comma separated list of transaction event types", - "examples": [ - "smart_contract_log,stx_lock,stx_asset" - ], - "type": "string" + "type": "string", + "enum": [ + "stx_lock" + ] + }, + { + "type": "string", + "enum": [ + "stx_asset" + ] + }, + { + "type": "string", + "enum": [ + "fungible_token_asset" + ] + }, + { + "type": "string", + "enum": [ + "non_fungible_token_asset" + ] } ] } @@ -118968,13 +118931,12 @@ "type": "string" }, { - "pattern": "^[0-9]+$", "title": "Block height", "description": "Block height", "examples": [ - "777678" + 777678 ], - "type": "string" + "type": "integer" } ] }, @@ -119149,13 +119111,12 @@ "type": "string" }, { - "pattern": "^[0-9]+$", "title": "Block height", "description": "Block height", "examples": [ - "777678" + 777678 ], - "type": "string" + "type": "integer" } ] }, @@ -125646,13 +125607,12 @@ "type": "string" }, { - "pattern": "^[0-9]+$", "title": "Burn block height", "description": "Burn block height", "examples": [ - "777678" + 777678 ], - "type": "string" + "type": "integer" } ] }, @@ -125770,13 +125730,12 @@ "type": "string" }, { - "pattern": "^[0-9]+$", "title": "Burn block height", "description": "Burn block height", "examples": [ - "777678" + 777678 ], - "type": "string" + "type": "integer" } ] }, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e8ae62bac..44a3e4b04 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -107,30 +107,24 @@ paths: type: array items: anyOf: - - anyOf: - - type: string - enum: - - coinbase - - type: string - enum: - - token_transfer - - type: string - enum: - - smart_contract - - type: string - enum: - - contract_call - - type: string - enum: - - poison_microblock - - type: string - enum: - - tenure_change - - pattern: ^(coinbase|token_transfer|smart_contract|contract_call|poison_microblock|tenure_change)(,(coinbase|token_transfer|smart_contract|contract_call|poison_microblock|tenure_change))*$ - description: Comma separated list of transaction types - examples: - - coinbase,token_transfer,smart_contract - type: string + - type: string + enum: + - coinbase + - type: string + enum: + - token_transfer + - type: string + enum: + - smart_contract + - type: string + enum: + - contract_call + - type: string + enum: + - poison_microblock + - type: string + enum: + - tenure_change in: query name: type required: false @@ -4447,20 +4441,13 @@ paths: - schema: type: array items: - anyOf: - - pattern: ^(0x)?[a-fA-F0-9]{64}$ - title: Transaction ID - description: Transaction ID - examples: - - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13d\ - f3dd7a91c6" - type: string - - pattern: ^(0x)?[a-fA-F0-9]{64}(,(0x)?[a-fA-F0-9]{64})*$ - title: Comma separated list of transaction IDs - description: Comma separate list of transaction IDs - examples: - - 0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6,0xbf06fc00be4333166b9a9be0557b9f560bee8700dfe7988bd3d3df1f1a077ed8 - type: string + pattern: ^(0x)?[a-fA-F0-9]{64}$ + title: Transaction ID + description: Transaction ID + examples: + - "0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd\ + 7a91c6" + type: string in: query name: tx_id required: true @@ -14949,27 +14936,21 @@ paths: type: array items: anyOf: - - anyOf: - - type: string - enum: - - smart_contract_log - - type: string - enum: - - stx_lock - - type: string - enum: - - stx_asset - - type: string - enum: - - fungible_token_asset - - type: string - enum: - - non_fungible_token_asset - - pattern: ^(smart_contract_log|stx_lock|stx_asset|fungible_token_asset|non_fungible_token_asset)(,(smart_contract_log|stx_lock|stx_asset|fungible_token_asset|non_fungible_token_asset))*$ - description: Comma separated list of transaction event types - examples: - - smart_contract_log,stx_lock,stx_asset - type: string + - type: string + enum: + - smart_contract_log + - type: string + enum: + - stx_lock + - type: string + enum: + - stx_asset + - type: string + enum: + - fungible_token_asset + - type: string + enum: + - non_fungible_token_asset in: query name: type required: false @@ -79438,12 +79419,11 @@ paths: examples: - daf79950c5e8bb0c620751333967cdd62297137cdaf79950c5e8bb0c62075133 type: string - - pattern: ^[0-9]+$ - title: Block height + - title: Block height description: Block height examples: - - "777678" - type: string + - 777678 + type: integer in: path name: height_or_hash required: true @@ -79573,12 +79553,11 @@ paths: examples: - daf79950c5e8bb0c620751333967cdd62297137cdaf79950c5e8bb0c62075133 type: string - - pattern: ^[0-9]+$ - title: Block height + - title: Block height description: Block height examples: - - "777678" - type: string + - 777678 + type: integer in: path name: height_or_hash required: true @@ -83904,12 +83883,11 @@ paths: examples: - 0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133 type: string - - pattern: ^[0-9]+$ - title: Burn block height + - title: Burn block height description: Burn block height examples: - - "777678" - type: string + - 777678 + type: integer in: path name: height_or_hash required: true @@ -83993,12 +83971,11 @@ paths: examples: - 0000000000000000000452773967cdd62297137cdaf79950c5e8bb0c62075133 type: string - - pattern: ^[0-9]+$ - title: Burn block height + - title: Burn block height description: Burn block height examples: - - "777678" - type: string + - 777678 + type: integer in: path name: height_or_hash required: true diff --git a/src/api/routes/tx.ts b/src/api/routes/tx.ts index 970cbe6e2..5e1acb2b5 100644 --- a/src/api/routes/tx.ts +++ b/src/api/routes/tx.ts @@ -29,7 +29,6 @@ import { OffsetParam, OrderParamSchema, PrincipalSchema, - TransactionIdCommaListParamSchema, TransactionIdParamSchema, UnanchoredParamSchema, } from '../schemas/params'; @@ -43,7 +42,6 @@ import { TransactionSchema, TransactionSearchResponseSchema, TransactionTypeSchema, - TransactionTypeStringSchema, } from '../schemas/entities/transactions'; import { PaginatedResponse } from '../schemas/util'; import { @@ -54,11 +52,7 @@ import { TransactionEventsResponseSchema, TransactionResultsSchema, } from '../schemas/responses/responses'; -import { - TransactionEventSchema, - TransactionEventTypeCommaListSchema, - TransactionEventTypeSchema, -} from '../schemas/entities/transaction-events'; +import { TransactionEventTypeSchema } from '../schemas/entities/transaction-events'; export const TxRoutes: FastifyPluginAsync< Record, @@ -69,6 +63,12 @@ export const TxRoutes: FastifyPluginAsync< '/', { preHandler: handleChainTipCache, + preValidation: (req, _reply, done) => { + if (typeof req.query.type === 'string') { + req.query.type = (req.query.type as string).split(',') as typeof req.query.type; + } + done(); + }, schema: { operationId: 'get_transaction_list', summary: 'Get recent transactions', @@ -77,9 +77,7 @@ export const TxRoutes: FastifyPluginAsync< querystring: Type.Object({ offset: OffsetParam(), limit: LimitParam(ResourceType.Tx), - type: Type.Optional( - Type.Array(Type.Union([TransactionTypeSchema, TransactionTypeStringSchema])) - ), + type: Type.Optional(Type.Array(TransactionTypeSchema)), unanchored: UnanchoredParamSchema, order: Type.Optional(Type.Enum({ asc: 'asc', desc: 'desc' })), sort_by: Type.Optional( @@ -145,8 +143,7 @@ export const TxRoutes: FastifyPluginAsync< const limit = getPagingQueryLimit(ResourceType.Tx, req.query.limit); const offset = parsePagingQueryInput(req.query.offset ?? 0); - const typeQuery = req.query.type?.flatMap(t => t.split(',')); - const txTypeFilter = parseTxTypeStrings(typeQuery ?? []); + const txTypeFilter = parseTxTypeStrings(req.query.type ?? []); let fromAddress: string | undefined; if (typeof req.query.from_address === 'string') { @@ -205,16 +202,20 @@ export const TxRoutes: FastifyPluginAsync< '/multiple', { preHandler: handleMempoolCache, + preValidation: (req, _reply, done) => { + if (typeof req.query.tx_id === 'string') { + req.query.tx_id = (req.query.tx_id as string).split(',') as typeof req.query.tx_id; + } + done(); + }, schema: { operationId: 'get_tx_list_details', summary: 'Get list of details for transactions', description: `Retrieves a list of transactions for a given list of transaction IDs`, tags: ['Transactions'], querystring: Type.Object({ - tx_id: Type.Array( - Type.Union([TransactionIdParamSchema, TransactionIdCommaListParamSchema]) - ), - event_limit: LimitParam(ResourceType.Tx), + tx_id: Type.Array(TransactionIdParamSchema), + event_limit: LimitParam(ResourceType.Event), event_offset: OffsetParam(), unanchored: UnanchoredParamSchema, }), @@ -224,14 +225,12 @@ export const TxRoutes: FastifyPluginAsync< }, }, async (req, reply) => { - const txList: string[] = req.query.tx_id.flatMap(t => t.split(',')); - - const eventLimit = getPagingQueryLimit(ResourceType.Tx, req.query.event_limit); + const eventLimit = getPagingQueryLimit(ResourceType.Event, req.query.event_limit); const eventOffset = parsePagingQueryInput(req.query.event_offset ?? 0); const includeUnanchored = req.query.unanchored ?? false; - txList.forEach(tx => validateRequestHexInput(tx)); + req.query.tx_id.forEach(tx => validateRequestHexInput(tx)); const txQuery = await searchTxs(fastify.db, { - txIds: txList, + txIds: req.query.tx_id, eventLimit, eventOffset, includeUnanchored, @@ -382,6 +381,12 @@ export const TxRoutes: FastifyPluginAsync< '/events', { preHandler: handleChainTipCache, + preValidation: (req, _reply, done) => { + if (typeof req.query.type === 'string') { + req.query.type = (req.query.type as string).split(',') as typeof req.query.type; + } + done(); + }, schema: { operationId: 'get_filtered_events', summary: 'Transaction Events', @@ -391,11 +396,7 @@ export const TxRoutes: FastifyPluginAsync< querystring: Type.Object({ tx_id: Type.Optional(TransactionIdParamSchema), address: Type.Optional(PrincipalSchema), - type: Type.Optional( - Type.Array( - Type.Union([TransactionEventTypeSchema, TransactionEventTypeCommaListSchema]) - ) - ), + type: Type.Optional(Type.Array(TransactionEventTypeSchema)), offset: OffsetParam(), limit: LimitParam(ResourceType.Event), }), @@ -433,7 +434,7 @@ export const TxRoutes: FastifyPluginAsync< validateRequestHexInput(addrOrTx.txId); } - const typeQuery = req.query.type?.flatMap(t => t.split(',')); + const typeQuery = req.query.type; let eventTypeFilter: DbEventTypeId[]; if (typeQuery && typeQuery.length > 0) { try { @@ -477,7 +478,7 @@ export const TxRoutes: FastifyPluginAsync< tx_id: TransactionIdParamSchema, }), querystring: Type.Object({ - event_limit: LimitParam(ResourceType.Tx), + event_limit: LimitParam(ResourceType.Event), event_offset: OffsetParam(), unanchored: UnanchoredParamSchema, }), @@ -494,7 +495,7 @@ export const TxRoutes: FastifyPluginAsync< return reply.redirect('/extended/v1/tx/0x' + req.params.tx_id + url.search); } - const eventLimit = getPagingQueryLimit(ResourceType.Tx, req.query['event_limit'], 100); + const eventLimit = getPagingQueryLimit(ResourceType.Event, req.query['event_limit'], 100); const eventOffset = parsePagingQueryInput(req.query['event_offset'] ?? 0); const includeUnanchored = req.query.unanchored ?? false; validateRequestHexInput(tx_id); @@ -526,7 +527,7 @@ export const TxRoutes: FastifyPluginAsync< tx_id: TransactionIdParamSchema, }), querystring: Type.Object({ - event_limit: LimitParam(ResourceType.Tx), + event_limit: LimitParam(ResourceType.Event), event_offset: OffsetParam(), }), response: { diff --git a/src/api/routes/v2/blocks.ts b/src/api/routes/v2/blocks.ts index 08076c8cc..ed7f138e8 100644 --- a/src/api/routes/v2/blocks.ts +++ b/src/api/routes/v2/blocks.ts @@ -1,5 +1,5 @@ import { handleChainTipCache } from '../../../api/controllers/cache-controller'; -import { BlockParamsSchema, parseBlockParam } from './schemas'; +import { BlockParamsSchema, cleanBlockHeightOrHashParam, parseBlockParam } from './schemas'; import { parseDbNakamotoBlock } from './helpers'; import { InvalidRequestError, NotFoundError } from '../../../errors'; import { parseDbTx } from '../../../api/controllers/db-controller'; @@ -92,6 +92,10 @@ export const BlockRoutesV2: FastifyPluginAsync< '/:height_or_hash', { preHandler: handleChainTipCache, + preValidation: (req, _reply, done) => { + cleanBlockHeightOrHashParam(req.params); + done(); + }, schema: { operationId: 'get_block', summary: 'Get block', @@ -117,6 +121,10 @@ export const BlockRoutesV2: FastifyPluginAsync< '/:height_or_hash/transactions', { preHandler: handleChainTipCache, + preValidation: (req, _reply, done) => { + cleanBlockHeightOrHashParam(req.params); + done(); + }, schema: { operationId: 'get_transactions_by_block', summary: 'Get transactions by block', diff --git a/src/api/routes/v2/burn-blocks.ts b/src/api/routes/v2/burn-blocks.ts index 299d5f2fe..93cf8a897 100644 --- a/src/api/routes/v2/burn-blocks.ts +++ b/src/api/routes/v2/burn-blocks.ts @@ -1,6 +1,6 @@ import { handleChainTipCache } from '../../controllers/cache-controller'; import { parseDbBurnBlock, parseDbNakamotoBlock } from './helpers'; -import { BurnBlockParamsSchema, parseBlockParam } from './schemas'; +import { BurnBlockParamsSchema, cleanBlockHeightOrHashParam, parseBlockParam } from './schemas'; import { InvalidRequestError, NotFoundError } from '../../../errors'; import { FastifyPluginAsync } from 'fastify'; import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; @@ -51,6 +51,10 @@ export const BurnBlockRoutesV2: FastifyPluginAsync< '/:height_or_hash', { preHandler: handleChainTipCache, + preValidation: (req, _reply, done) => { + cleanBlockHeightOrHashParam(req.params); + done(); + }, schema: { operationId: 'get_burn_block', summary: 'Get burn block', @@ -77,6 +81,10 @@ export const BurnBlockRoutesV2: FastifyPluginAsync< '/:height_or_hash/blocks', { preHandler: handleChainTipCache, + preValidation: (req, _reply, done) => { + cleanBlockHeightOrHashParam(req.params); + done(); + }, schema: { operationId: 'get_blocks_by_burn_block', summary: 'Get blocks by burn block', diff --git a/src/api/routes/v2/schemas.ts b/src/api/routes/v2/schemas.ts index 9285d52b3..8591c6ffa 100644 --- a/src/api/routes/v2/schemas.ts +++ b/src/api/routes/v2/schemas.ts @@ -68,10 +68,11 @@ export type BlockIdParam = | { type: 'hash'; hash: string } | { type: 'latest'; latest: true }; -export function parseBlockParam(value: string): BlockIdParam { +export function parseBlockParam(value: string | number): BlockIdParam { if (value === 'latest') { return { type: 'latest', latest: true }; } + value = typeof value === 'string' ? value : value.toString(); if (/^(0x)?[a-fA-F0-9]{64}$/i.test(value)) { return { type: 'hash', hash: has0xPrefix(value) ? value : `0x${value}` }; } @@ -81,6 +82,19 @@ export function parseBlockParam(value: string): BlockIdParam { throw new Error('Invalid block height or hash'); } +/** + * If a param can accept a block hash or height, then ensure that the hash is prefixed with '0x' so + * that hashes with only digits are not accidentally parsed as a number. + */ +export function cleanBlockHeightOrHashParam(params: { height_or_hash: string | number }) { + if ( + typeof params.height_or_hash === 'string' && + /^[a-fA-F0-9]{64}$/i.test(params.height_or_hash) + ) { + params.height_or_hash = '0x' + params.height_or_hash; + } +} + const BurnBlockHashParamSchema = Type.String({ pattern: isTestEnv ? undefined : '^(0x)?[a-fA-F0-9]{64}$', title: 'Burn block hash', @@ -89,18 +103,16 @@ const BurnBlockHashParamSchema = Type.String({ }); export const CompiledBurnBlockHashParam = ajv.compile(BurnBlockHashParamSchema); -const BurnBlockHeightParamSchema = Type.String({ - pattern: isTestEnv ? undefined : '^[0-9]+$', +const BurnBlockHeightParamSchema = Type.Integer({ title: 'Burn block height', description: 'Burn block height', - examples: ['777678'], + examples: [777678], }); -const BlockHeightParamSchema = Type.String({ - pattern: isTestEnv ? undefined : '^[0-9]+$', +const BlockHeightParamSchema = Type.Integer({ title: 'Block height', description: 'Block height', - examples: ['777678'], + examples: [777678], }); const BlockHashParamSchema = Type.String({ @@ -187,23 +199,6 @@ export const BurnBlockParamsSchema = Type.Object( ); export type BurnBlockParams = Static; -const PoxCycleParamsSchema = Type.Object( - { cycle_number: Type.String({ pattern: '^[0-9]+$' }) }, - { additionalProperties: false } -); -export type PoxCycleParams = Static; - -const PoxCycleSignerParamsSchema = Type.Object( - { - cycle_number: Type.String({ pattern: '^[0-9]+$' }), - signer_key: Type.String({ - pattern: '^(0x)?[a-fA-F0-9]{66}$', - }), - }, - { additionalProperties: false } -); -export type PoxCycleSignerParams = Static; - export const SmartContractStatusParamsSchema = Type.Object( { contract_id: Type.Union([Type.Array(SmartContractIdParamSchema), SmartContractIdParamSchema]), diff --git a/src/api/schemas/entities/transaction-events.ts b/src/api/schemas/entities/transaction-events.ts index c3913f52f..857039671 100644 --- a/src/api/schemas/entities/transaction-events.ts +++ b/src/api/schemas/entities/transaction-events.ts @@ -1,5 +1,4 @@ import { Static, Type } from '@sinclair/typebox'; -import { CommaStringList } from '../util'; export const TransactionEventAssetTypeSchema = Type.Enum({ transfer: 'transfer', @@ -15,10 +14,6 @@ const TransactionEventType = { non_fungible_token_asset: 'non_fungible_token_asset', }; export const TransactionEventTypeSchema = Type.Enum(TransactionEventType); -export const TransactionEventTypeCommaListSchema = CommaStringList(TransactionEventType, { - description: 'Comma separated list of transaction event types', - examples: ['smart_contract_log,stx_lock,stx_asset'], -}); const AbstractTransactionEventSchema = Type.Object( { diff --git a/src/api/schemas/entities/transactions.ts b/src/api/schemas/entities/transactions.ts index fd2450b18..a683571e1 100644 --- a/src/api/schemas/entities/transactions.ts +++ b/src/api/schemas/entities/transactions.ts @@ -1,5 +1,5 @@ import { Static, Type } from '@sinclair/typebox'; -import { CommaStringList, Nullable } from '../util'; +import { Nullable } from '../util'; import { PostConditionModeSchema, PostConditionSchema } from './post-conditions'; import { TransactionEventSchema } from './transaction-events'; @@ -12,11 +12,6 @@ const TransactionType = { tenure_change: 'tenure_change', } as const; export const TransactionTypeSchema = Type.Enum(TransactionType); -// Comma-separated list of transaction types, e.g. `coinbase,token_transfer` -export const TransactionTypeStringSchema = CommaStringList(TransactionType, { - description: 'Comma separated list of transaction types', - examples: ['coinbase,token_transfer,smart_contract'], -}); export const BaseTransactionSchemaProperties = { tx_id: Type.String({ diff --git a/src/api/schemas/params.ts b/src/api/schemas/params.ts index 43f08131b..7746d36b3 100644 --- a/src/api/schemas/params.ts +++ b/src/api/schemas/params.ts @@ -38,15 +38,6 @@ export const TransactionIdParamSchema = Type.String({ examples: ['0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6'], }); -export const TransactionIdCommaListParamSchema = Type.String({ - pattern: isTestEnv ? undefined : '^(0x)?[a-fA-F0-9]{64}(,(0x)?[a-fA-F0-9]{64})*$', - title: 'Comma separated list of transaction IDs', - description: 'Comma separate list of transaction IDs', - examples: [ - '0xf6bd5f4a7b26184a3466340b2e99fd003b4962c0e382a7e4b6a13df3dd7a91c6,0xbf06fc00be4333166b9a9be0557b9f560bee8700dfe7988bd3d3df1f1a077ed8', - ], -}); - export const BlockHeightSchema = Type.Integer({ minimum: 0, title: 'Block height', diff --git a/src/api/schemas/util.ts b/src/api/schemas/util.ts index 7c6275fce..ea60a75d3 100644 --- a/src/api/schemas/util.ts +++ b/src/api/schemas/util.ts @@ -1,11 +1,4 @@ -import { - ObjectOptions, - StringOptions, - TEnumKey, - TEnumValue, - TSchema, - Type, -} from '@sinclair/typebox'; +import { ObjectOptions, TSchema, Type } from '@sinclair/typebox'; export const Nullable = (schema: T) => Type.Union([schema, Type.Null()]); export const OptionalNullable = (schema: T) => Type.Optional(Nullable(schema)); @@ -19,15 +12,3 @@ export const PaginatedResponse = (type: T, options?: ObjectOp }, options ); - -// Comma-separated list of enum values, e.g. `age,size,fee` -export const CommaStringList = >( - item: T, - options?: StringOptions -) => { - const anyItemPattern = Object.values(item).join('|'); - return Type.String({ - pattern: `^(${anyItemPattern})(,(${anyItemPattern}))*$`, - ...options, - }); -}; diff --git a/src/datastore/pg-store-v2.ts b/src/datastore/pg-store-v2.ts index 390b8719d..a962fbe4f 100644 --- a/src/datastore/pg-store-v2.ts +++ b/src/datastore/pg-store-v2.ts @@ -12,10 +12,8 @@ import { AddressTransactionParams, PoxCyclePaginationQueryParams, PoxCycleLimitParamSchema, - PoxCycleParams, PoxSignerPaginationQueryParams, PoxSignerLimitParamSchema, - PoxCycleSignerParams, BlockIdParam, } from '../api/routes/v2/schemas'; import { InvalidRequestError, InvalidRequestErrorType } from '../errors'; diff --git a/src/test-utils/test-builders.ts b/src/test-utils/test-builders.ts index fe91ac6bb..220c2496f 100644 --- a/src/test-utils/test-builders.ts +++ b/src/test-utils/test-builders.ts @@ -24,6 +24,7 @@ import { DbSmartContractEvent, DbStxEvent, DbStxLockEvent, + DbTxRaw, DbTxStatus, DbTxTypeId, } from '../datastore/common'; @@ -156,7 +157,7 @@ function testMicroblock(args?: TestMicroblockArgs): DbMicroblockPartial { }; } -export interface TestTxArgs { +export interface TestTxArgs extends Partial { block_hash?: string; block_height?: number; burn_block_time?: number; @@ -199,9 +200,9 @@ function testTx(args?: TestTxArgs): DataStoreTxEventData { tx: { tx_id: args?.tx_id ?? TX_ID, tx_index: args?.tx_index ?? 0, - anchor_mode: 3, + anchor_mode: args?.anchor_mode ?? 3, nonce: args?.nonce ?? 0, - raw_tx: '', + raw_tx: args?.raw_tx ?? '', index_block_hash: args?.index_block_hash ?? INDEX_BLOCK_HASH, block_hash: args?.block_hash ?? BLOCK_HASH, block_height: args?.block_height ?? BLOCK_HEIGHT, @@ -213,18 +214,18 @@ function testTx(args?: TestTxArgs): DataStoreTxEventData { status: args?.status ?? DbTxStatus.Success, raw_result: args?.raw_result ?? '0x0703', canonical: args?.canonical ?? true, - post_conditions: '0x01f5', + post_conditions: args?.post_conditions ?? '0x01f5', fee_rate: args?.fee_rate ?? FEE_RATE, - sponsored: false, - sponsor_address: undefined, + sponsored: args?.sponsored ?? false, + sponsor_address: args?.sponsor_address ?? undefined, sender_address: args?.sender_address ?? SENDER_ADDRESS, - origin_hash_mode: 1, - coinbase_payload: bufferToHex(Buffer.from('hi')), + origin_hash_mode: args?.origin_hash_mode ?? 1, + coinbase_payload: args?.coinbase_payload ?? bufferToHex(Buffer.from('hi')), coinbase_alt_recipient: args?.coinbase_alt_recipient, coinbase_vrf_proof: args?.coinbase_vrf_proof, - event_count: 0, + event_count: args?.event_count ?? 0, parent_index_block_hash: args?.parent_index_block_hash ?? INDEX_BLOCK_HASH, - parent_block_hash: BLOCK_HASH, + parent_block_hash: args?.parent_block_hash ?? BLOCK_HASH, microblock_canonical: args?.microblock_canonical ?? true, microblock_sequence: args?.microblock_sequence ?? 0, microblock_hash: args?.microblock_hash ?? MICROBLOCK_HASH, @@ -239,10 +240,20 @@ function testTx(args?: TestTxArgs): DataStoreTxEventData { execution_cost_runtime: 0, execution_cost_write_count: 0, execution_cost_write_length: 0, + poison_microblock_header_1: args?.poison_microblock_header_1, + poison_microblock_header_2: args?.poison_microblock_header_2, + sponsor_nonce: args?.sponsor_nonce, contract_call_contract_id: args?.contract_call_contract_id, contract_call_function_name: args?.contract_call_function_name, contract_call_function_args: args?.contract_call_function_args, abi: args?.abi, + tenure_change_tenure_consensus_hash: args?.tenure_change_tenure_consensus_hash, + tenure_change_prev_tenure_consensus_hash: args?.tenure_change_prev_tenure_consensus_hash, + tenure_change_burn_view_consensus_hash: args?.tenure_change_burn_view_consensus_hash, + tenure_change_previous_tenure_end: args?.tenure_change_previous_tenure_end, + tenure_change_previous_tenure_blocks: args?.tenure_change_previous_tenure_blocks, + tenure_change_cause: args?.tenure_change_cause, + tenure_change_pubkey_hash: args?.tenure_change_pubkey_hash, }, stxLockEvents: [], stxEvents: [], diff --git a/src/tests/block-tests.ts b/src/tests/block-tests.ts index dccd2c6b5..b5a1b2335 100644 --- a/src/tests/block-tests.ts +++ b/src/tests/block-tests.ts @@ -824,6 +824,27 @@ describe('block tests', () => { expect(json).toStrictEqual(block5); }); + test('blocks v2 retrieved by digit-only hash', async () => { + const block = new TestBlockBuilder({ + block_height: 1, + block_hash: `0x1111111111111111111111111111111111111111111111111111111111111111`, + index_block_hash: `0x1111111111111111111111111111111111111111111111111111111111111111`, + parent_index_block_hash: `0x0000000000000000000000000000000000000000000000000000000000000000`, + parent_block_hash: `0x0000000000000000000000000000000000000000000000000000000000000000`, + burn_block_height: 700000, + burn_block_hash: '0x00000000000000000001e2ee7f0c6bd5361b5e7afd76156ca7d6f524ee5ca3d8', + }).build(); + await db.update(block); + + // Get by hash + const fetch = await supertest(api.server).get( + `/extended/v2/blocks/1111111111111111111111111111111111111111111111111111111111111111` + ); + const json = JSON.parse(fetch.text); + expect(fetch.status).toBe(200); + expect(json.height).toStrictEqual(block.block.block_height); + }); + test('blocks average time', async () => { const blockCount = 50; const now = Math.round(Date.now() / 1000); diff --git a/src/tests/tx-tests.ts b/src/tests/tx-tests.ts index c3cfcdf88..ba61925e9 100644 --- a/src/tests/tx-tests.ts +++ b/src/tests/tx-tests.ts @@ -22,7 +22,7 @@ import { } from '@stacks/transactions'; import { createClarityValueArray } from '../stacks-encoding-helpers'; import { decodeTransaction, TxPayloadVersionedSmartContract } from 'stacks-encoding-native-js'; -import { getTxFromDataStore } from '../api/controllers/db-controller'; +import { getTxFromDataStore, TransactionType } from '../api/controllers/db-controller'; import { DbBlock, DbTxRaw, @@ -2547,6 +2547,85 @@ describe('tx tests', () => { ); }); + test('tx list - filter by tx-type', async () => { + const testSendertAddr = 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y'; + const block1 = new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x01', + burn_block_time: 1710000000, + }) + .addTx({ + tx_id: '0x1234', + fee_rate: 1n, + sender_address: testSendertAddr, + nonce: 1, + type_id: DbTxTypeId.Coinbase, + }) + .build(); + + await db.update(block1); + + const block2 = new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x02', + parent_block_hash: block1.block.block_hash, + parent_index_block_hash: block1.block.index_block_hash, + burn_block_time: 1720000000, + }) + .addTx({ + tx_id: '0x2234', + fee_rate: 3n, + sender_address: testSendertAddr, + nonce: 2, + type_id: DbTxTypeId.PoisonMicroblock, + poison_microblock_header_1: '0x01', + poison_microblock_header_2: '0x02', + }) + .build(); + await db.update(block2); + + const block3 = new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x03', + parent_block_hash: block2.block.block_hash, + parent_index_block_hash: block2.block.index_block_hash, + burn_block_time: 1730000000, + }) + .addTx({ + tx_id: '0x3234', + fee_rate: 2n, + sender_address: testSendertAddr, + nonce: 3, + type_id: DbTxTypeId.TokenTransfer, + token_transfer_amount: 123456n, + token_transfer_memo: '0x1234', + token_transfer_recipient_address: 'ST27W5M8BRKA7C5MZE2R1S1F4XTPHFWFRNHA9M04Y', + }) + .build(); + await db.update(block3); + + const filterTypes: TransactionType[] = ['coinbase', 'poison_microblock', 'token_transfer']; + const txsReq1 = await supertest(api.server).get( + `/extended/v1/tx?type=${filterTypes.join(',')}` + ); + expect(txsReq1.status).toBe(200); + expect(txsReq1.body).toEqual( + expect.objectContaining({ + results: [ + expect.objectContaining({ + tx_id: block3.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block2.txs[0].tx.tx_id, + }), + expect.objectContaining({ + tx_id: block1.txs[0].tx.tx_id, + }), + ], + }) + ); + }); + test('fetch raw tx', async () => { const block: DbBlock = { block_hash: '0x1234',