From ed87f150d1bc860712f467def1cc341322273d36 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 12 Nov 2024 11:47:44 +0100 Subject: [PATCH 1/6] chore: more details in event-observer http server logging (#2160) * fix: more details in event-observer http server logging * chore: misc cleanup --- src/event-stream/event-server.ts | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/event-stream/event-server.ts b/src/event-stream/event-server.ts index 2a2154b73..64fbf4f45 100644 --- a/src/event-stream/event-server.ts +++ b/src/event-stream/event-server.ts @@ -757,10 +757,32 @@ export async function startEventServer(opts: { } const bodyLimit = 1_000_000 * 500; // 500MB body limit + + const reqLogSerializer = (req: FastifyRequest) => ({ + method: req.method, + url: req.url, + version: req.headers?.['accept-version'] as string, + hostname: req.hostname, + remoteAddress: req.ip, + remotePort: req.socket?.remotePort, + bodySize: parseInt(req.headers?.['content-length'] as string) || 'unknown', + }); + const loggerOpts: FastifyServerOptions['logger'] = { ...PINO_LOGGER_CONFIG, name: 'stacks-node-event', + serializers: { + req: reqLogSerializer, + res: reply => ({ + statusCode: reply.statusCode, + method: reply.request?.method, + url: reply.request?.url, + requestBodySize: parseInt(reply.request?.headers['content-length'] as string) || 'unknown', + responseBodySize: parseInt(reply.getHeader?.('content-length') as string) || 'unknown', + }), + }, }; + const app = Fastify({ bodyLimit, trustProxy: true, @@ -768,6 +790,18 @@ export async function startEventServer(opts: { ignoreTrailingSlash: true, }); + app.addHook('onRequest', (req, reply, done) => { + req.raw.on('close', () => { + if (req.raw.aborted) { + req.log.warn( + reqLogSerializer(req), + `Request was aborted by the client: ${req.method} ${req.url}` + ); + } + }); + done(); + }); + const handleRawEventRequest = async (req: FastifyRequest) => { await messageHandler.handleRawEventRequest(req.url, req.body, db); From b17895b93bd4ec70a4d623c7064d148e58b0470e Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 12 Nov 2024 11:48:27 +0100 Subject: [PATCH 2/6] docs: default 4xx error schemas for all routes (#2159) --- .vscode/launch.json | 7 +++++++ src/api/schemas/responses/responses.ts | 3 ++- src/openapi-generator.ts | 13 ++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 1a90b38bb..72a01bd2f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -460,6 +460,13 @@ "TS_NODE_SKIP_IGNORE": "true" } }, + { + "type": "node", + "request": "launch", + "name": "docs: openapi-generator", + "runtimeArgs": ["-r", "ts-node/register/transpile-only"], + "args": ["${workspaceFolder}/src/openapi-generator.ts"] + }, { "type": "node", "request": "launch", diff --git a/src/api/schemas/responses/responses.ts b/src/api/schemas/responses/responses.ts index 160075d42..9903fdcc1 100644 --- a/src/api/schemas/responses/responses.ts +++ b/src/api/schemas/responses/responses.ts @@ -17,8 +17,9 @@ import { NakamotoBlockSchema, SignerSignatureSchema } from '../entities/block'; export const ErrorResponseSchema = Type.Object( { error: Type.String(), + message: Type.Optional(Type.String()), }, - { title: 'Error Response' } + { title: 'Error Response', additionalProperties: true } ); export const ServerStatusResponseSchema = Type.Object( diff --git a/src/openapi-generator.ts b/src/openapi-generator.ts index 817e2492d..94ec7b537 100644 --- a/src/openapi-generator.ts +++ b/src/openapi-generator.ts @@ -1,9 +1,10 @@ import Fastify from 'fastify'; -import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import { TSchema, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import FastifySwagger from '@fastify/swagger'; import { writeFileSync } from 'fs'; import { OpenApiSchemaOptions } from './api/schemas/openapi'; import { StacksApiRoutes } from './api/init'; +import { ErrorResponseSchema } from './api/schemas/responses/responses'; /** * Generates `openapi.yaml` based on current Swagger definitions. @@ -14,6 +15,16 @@ async function generateOpenApiFiles() { logger: true, }).withTypeProvider(); + // If a response schema is defined but lacks a '4xx' response, add it + fastify.addHook( + 'onRoute', + (route: { schema?: { response: Record } }) => { + if (route.schema?.response && !route.schema?.response['4xx']) { + route.schema.response['4xx'] = ErrorResponseSchema; + } + } + ); + await fastify.register(FastifySwagger, OpenApiSchemaOptions); await fastify.register(StacksApiRoutes); await fastify.ready(); From 9b7e97d8d3faf94b1d17162cb2bf17f214afe172 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 19 Nov 2024 18:46:03 +0100 Subject: [PATCH 3/6] feat: option to modify estimated fees in /v2/fees/transaction proxy (#2172) * feat: option to modify estimated fees in /v2/fees/transaction proxy * chore: lazy load env STACKS_CORE_FEE_ESTIMATION_MODIFIER * chore: use undici for rpc proxy tests --- package-lock.json | 42 ++++++++++--- package.json | 1 + src/api/routes/core-node-rpc-proxy.ts | 55 ++++++++++++++++ tests/api/v2-proxy.test.ts | 90 +++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 655b04a07..4f4f74ad2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,11 +41,11 @@ "elliptic": "6.5.7", "escape-goat": "3.0.0", "evt": "1.10.1", - "express": "^4.21.1", + "express": "4.21.1", "fastify": "4.28.1", "fastify-metrics": "11.0.0", "getopts": "2.3.0", - "http-proxy-middleware": "^2.0.7", + "http-proxy-middleware": "2.0.7", "jsonc-parser": "3.0.0", "jsonrpc-lite": "2.2.0", "lru-cache": "6.0.0", @@ -69,6 +69,7 @@ "strict-event-emitter-types": "2.0.0", "tiny-secp256k1": "2.2.1", "ts-unused-exports": "7.0.3", + "undici": "6.21.0", "uuid": "8.3.2", "ws": "7.5.10", "zone-file": "2.0.0-beta.3" @@ -171,6 +172,19 @@ "undici": "^5.25.4" } }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@actions/io": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.1.tgz", @@ -1255,6 +1269,18 @@ "undici": "^5.19.1" } }, + "node_modules/@fastify/reply-from/node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@fastify/swagger": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-8.15.0.tgz", @@ -16928,14 +16954,12 @@ "dev": true }, "node_modules/undici": { - "version": "5.28.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", - "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", + "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "license": "MIT", "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 89bd0212d..06bbae90d 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "strict-event-emitter-types": "2.0.0", "tiny-secp256k1": "2.2.1", "ts-unused-exports": "7.0.3", + "undici": "6.21.0", "uuid": "8.3.2", "ws": "7.5.10", "zone-file": "2.0.0-beta.3" diff --git a/src/api/routes/core-node-rpc-proxy.ts b/src/api/routes/core-node-rpc-proxy.ts index 66b561be0..37b3e46bf 100644 --- a/src/api/routes/core-node-rpc-proxy.ts +++ b/src/api/routes/core-node-rpc-proxy.ts @@ -21,6 +21,26 @@ function getReqUrl(req: { url: string; hostname: string }): URL { return new URL(req.url, `http://${req.hostname}`); } +// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/chainstate/stacks/db/blocks.rs#L338 +const MINIMUM_TX_FEE_RATE_PER_BYTE = 1; + +interface FeeEstimation { + fee: number; + fee_rate: number; +} +interface FeeEstimateResponse { + cost_scalar_change_by_byte: number; + estimated_cost: { + read_count: number; + read_length: number; + runtime: number; + write_count: number; + write_length: number; + }; + estimated_cost_scalar: number; + estimations: [FeeEstimation, FeeEstimation, FeeEstimation]; +} + export const CoreNodeRpcProxyRouter: FastifyPluginAsync< Record, Server, @@ -117,10 +137,22 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync< } ); + let feeEstimationModifier: number | null = null; + fastify.addHook('onReady', () => { + const feeEstEnvVar = process.env['STACKS_CORE_FEE_ESTIMATION_MODIFIER']; + if (feeEstEnvVar) { + const parsed = parseFloat(feeEstEnvVar); + if (!isNaN(parsed) && parsed > 0) { + feeEstimationModifier = parsed; + } + } + }); + await fastify.register(fastifyHttpProxy, { upstream: `http://${stacksNodeRpcEndpoint}`, rewritePrefix: '/v2', http2: false, + globalAgent: true, preValidation: async (req, reply) => { if (getReqUrl(req).pathname !== '/v2/transactions') { return; @@ -201,6 +233,29 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync< const txId = responseBuffer.toString(); await logTxBroadcast(txId); await reply.send(responseBuffer); + } else if ( + getReqUrl(req).pathname === '/v2/fees/transaction' && + reply.statusCode === 200 && + feeEstimationModifier !== null + ) { + const reqBody = req.body as { + estimated_len?: number; + transaction_payload: string; + }; + // https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/net/api/postfeerate.rs#L200-L201 + const txSize = Math.max( + reqBody.estimated_len ?? 0, + reqBody.transaction_payload.length / 2 + ); + const minFee = txSize * MINIMUM_TX_FEE_RATE_PER_BYTE; + const modifier = feeEstimationModifier; + const responseBuffer = await readRequestBody(response as ServerResponse); + const responseJson = JSON.parse(responseBuffer.toString()) as FeeEstimateResponse; + responseJson.estimations.forEach(estimation => { + // max(min fee, estimate returned by node * configurable modifier) + estimation.fee = Math.max(minFee, Math.round(estimation.fee * modifier)); + }); + await reply.removeHeader('content-length').send(JSON.stringify(responseJson)); } else { await reply.send(response); } diff --git a/tests/api/v2-proxy.test.ts b/tests/api/v2-proxy.test.ts index a60564fa6..8f97e87b0 100644 --- a/tests/api/v2-proxy.test.ts +++ b/tests/api/v2-proxy.test.ts @@ -9,6 +9,7 @@ import * as nock from 'nock'; import { DbBlock } from '../../src/datastore/common'; import { PgWriteStore } from '../../src/datastore/pg-write-store'; import { migrate } from '../utils/test-helpers'; +import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; describe('v2-proxy tests', () => { let db: PgWriteStore; @@ -27,6 +28,95 @@ describe('v2-proxy tests', () => { await migrate('down'); }); + test('tx fee estimation', async () => { + const primaryProxyEndpoint = 'proxy-stacks-node:12345'; + const feeEstimationModifier = 0.5; + await useWithCleanup( + () => { + const restoreEnvVars = withEnvVars( + ['STACKS_CORE_FEE_ESTIMATION_MODIFIER', feeEstimationModifier.toString()], + ['STACKS_CORE_PROXY_HOST', primaryProxyEndpoint.split(':')[0]], + ['STACKS_CORE_PROXY_PORT', primaryProxyEndpoint.split(':')[1]] + ); + return [, () => restoreEnvVars()] as const; + }, + () => { + const agent = new MockAgent(); + const originalAgent = getGlobalDispatcher(); + setGlobalDispatcher(agent); + return [agent, () => setGlobalDispatcher(originalAgent)] as const; + }, + async () => { + const apiServer = await startApiServer({ + datastore: db, + chainId: ChainID.Mainnet, + }); + return [apiServer, apiServer.terminate] as const; + }, + async (_, mockAgent, api) => { + const primaryStubbedResponse = { + cost_scalar_change_by_byte: 0.00476837158203125, + estimated_cost: { + read_count: 19, + read_length: 4814, + runtime: 7175000, + write_count: 2, + write_length: 1020, + }, + estimated_cost_scalar: 14, + estimations: [ + { + fee: 400, + fee_rate: 1.2410714285714286, + }, + { + fee: 800, + fee_rate: 8.958333333333332, + }, + { + fee: 1000, + fee_rate: 10, + }, + ], + }; + const testRequest = { + estimated_len: 350, + transaction_payload: + '021af942874ce525e87f21bbe8c121b12fac831d02f4086765742d696e666f0b7570646174652d696e666f00000000', + }; + + mockAgent + .get(`http://${primaryProxyEndpoint}`) + .intercept({ + path: '/v2/fees/transaction', + method: 'POST', + }) + .reply(200, JSON.stringify(primaryStubbedResponse), { + headers: { 'Content-Type': 'application/json' }, + }); + + const postTxReq = await supertest(api.server) + .post(`/v2/fees/transaction`) + .set('Content-Type', 'application/json') + .send(JSON.stringify(testRequest)); + expect(postTxReq.status).toBe(200); + // Expected min fee is the byte size because MINIMUM_TX_FEE_RATE_PER_BYTE=1 + const expectedMinFee = Math.max( + testRequest.estimated_len ?? 0, + testRequest.transaction_payload.length / 2 + ); + const expectedResponse = { + ...primaryStubbedResponse, + }; + expectedResponse.estimations = expectedResponse.estimations.map(est => ({ + ...est, + fee: Math.max(expectedMinFee, Math.round(est.fee * feeEstimationModifier)), + })); + expect(postTxReq.body).toEqual(expectedResponse); + } + ); + }); + test('tx post multicast', async () => { const primaryProxyEndpoint = 'proxy-stacks-node:12345'; const extraTxEndpoint = 'http://extra-tx-endpoint-a/test'; From d344a7963530fb747f0ad1a7cd6912c1c7007b9e Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 19 Nov 2024 17:58:04 +0000 Subject: [PATCH 4/6] chore(release): 8.3.0-beta.1 [skip ci] ## [8.3.0-beta.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.2.2...v8.3.0-beta.1) (2024-11-19) ### Features * option to modify estimated fees in /v2/fees/transaction proxy ([#2172](https://github.com/hirosystems/stacks-blockchain-api/issues/2172)) ([9b7e97d](https://github.com/hirosystems/stacks-blockchain-api/commit/9b7e97d8d3faf94b1d17162cb2bf17f214afe172)) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd23a97dc..4634d0f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [8.3.0-beta.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.2.2...v8.3.0-beta.1) (2024-11-19) + + +### Features + +* option to modify estimated fees in /v2/fees/transaction proxy ([#2172](https://github.com/hirosystems/stacks-blockchain-api/issues/2172)) ([9b7e97d](https://github.com/hirosystems/stacks-blockchain-api/commit/9b7e97d8d3faf94b1d17162cb2bf17f214afe172)) + ## [8.2.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.2.1...v8.2.2) (2024-11-10) From b99c672112c92f92bf1421f3eae44b6861292de9 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Thu, 21 Nov 2024 15:54:10 +0100 Subject: [PATCH 5/6] fix: socket-io reconnection bug (#2174) * fix: socket-io reconnection bug * test: add socket-io reconnection test --- client/src/socket-io/index.ts | 2 +- .../routes/ws/channels/socket-io-channel.ts | 5 +- tests/api/socket-io.test.ts | 57 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/client/src/socket-io/index.ts b/client/src/socket-io/index.ts index 62578c7c0..3bba9f918 100644 --- a/client/src/socket-io/index.ts +++ b/client/src/socket-io/index.ts @@ -72,7 +72,7 @@ export class StacksApiSocketClient { handleSubscription(topic: Topic, subscribe = false, listener?: (...args: any[]) => void) { const subsQuery = this.socket.io.opts.query?.subscriptions as string | undefined; - const subscriptions = new Set(subsQuery?.split(',') ?? []); + const subscriptions = new Set(subsQuery ? subsQuery.split(',') : []); if (subscribe) { this.socket.emit('subscribe', topic, error => { if (error) console.error(`Error subscribing: ${error}`); diff --git a/src/api/routes/ws/channels/socket-io-channel.ts b/src/api/routes/ws/channels/socket-io-channel.ts index 9d373e281..963d871cc 100644 --- a/src/api/routes/ws/channels/socket-io-channel.ts +++ b/src/api/routes/ws/channels/socket-io-channel.ts @@ -90,7 +90,10 @@ export class SocketIOChannel extends WebSocketChannel { io.use((socket, next) => { const subscriptions = socket.handshake.query['subscriptions']; if (subscriptions) { - const topics = [...[subscriptions]].flat().flatMap(r => r.split(',')); + const topics = [...[subscriptions]] + .flat() + .flatMap(r => r.split(',')) + .filter(r => !!r); const invalidSubs = this.getInvalidSubscriptionTopics(topics as Topic[]); if (invalidSubs) { const error = new Error(`Invalid topic: ${invalidSubs.join(', ')}`); diff --git a/tests/api/socket-io.test.ts b/tests/api/socket-io.test.ts index 04627c405..c787fcad7 100644 --- a/tests/api/socket-io.test.ts +++ b/tests/api/socket-io.test.ts @@ -20,6 +20,7 @@ import { NftEvent, Transaction, } from 'client/src/types'; +import { Socket } from 'node:net'; describe('socket-io', () => { let apiServer: ApiServer; @@ -40,6 +41,62 @@ describe('socket-io', () => { await migrate('down'); }); + test('socket-io-client > reconnect', async () => { + const serverSocketConnectWaiter = waiter(); + apiServer.server.once('upgrade', (_req, socket: Socket) => { + serverSocketConnectWaiter.finish(socket); + }); + + const client = new StacksApiSocketClient({ + url: `http://${apiServer.address}`, + // socketOpts: { reconnection: false }, + }); + + const updateWaiter: Waiter = waiter(); + const subResult = client.subscribeBlocks(block => updateWaiter.finish(block)); + + // subscriptions should be saved in the client query obj + expect(client.socket.io.opts.query).toMatchObject({ subscriptions: 'block' }); + + // wait for initial client connection + await new Promise(resolve => client.socket.once('connect', resolve)); + + const connectAttempt = waiter(); + client.socket.io.once('reconnect_attempt', attempt => { + // subscriptions should be saved in the client query obj + expect(client.socket.io.opts.query).toMatchObject({ subscriptions: 'block' }); + connectAttempt.finish(); + }); + + const reconnectWaiter = waiter(); + client.socket.io.once('reconnect', () => reconnectWaiter.finish()); + + // force kill client connection on the server to trigger reconnect + const serverSocket = await serverSocketConnectWaiter; + serverSocket.resetAndDestroy(); + + await connectAttempt; + await reconnectWaiter; + + // ensure client still waiting for block update + expect(updateWaiter.isFinished).toBe(false); + + const block = new TestBlockBuilder({ block_hash: '0x1234', burn_block_hash: '0x5454' }) + .addTx({ tx_id: '0x4321' }) + .build(); + await db.update(block); + + const result = await updateWaiter; + try { + expect(result.hash).toEqual('0x1234'); + expect(result.burn_block_hash).toEqual('0x5454'); + expect(result.txs[0]).toEqual('0x4321'); + } finally { + subResult.unsubscribe(); + client.socket.close(); + } + }); + test('socket-io-client > block updates', async () => { const client = new StacksApiSocketClient({ url: `http://${apiServer.address}`, From aa151dbb7c69e58c3e960b4cf68ed5f66b5a351c Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 21 Nov 2024 15:06:29 +0000 Subject: [PATCH 6/6] chore(release): 8.3.0-beta.2 [skip ci] ## [8.3.0-beta.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.3.0-beta.1...v8.3.0-beta.2) (2024-11-21) ### Bug Fixes * socket-io reconnection bug ([#2174](https://github.com/hirosystems/stacks-blockchain-api/issues/2174)) ([b99c672](https://github.com/hirosystems/stacks-blockchain-api/commit/b99c672112c92f92bf1421f3eae44b6861292de9)) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4634d0f9a..3cad2f6a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [8.3.0-beta.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.3.0-beta.1...v8.3.0-beta.2) (2024-11-21) + + +### Bug Fixes + +* socket-io reconnection bug ([#2174](https://github.com/hirosystems/stacks-blockchain-api/issues/2174)) ([b99c672](https://github.com/hirosystems/stacks-blockchain-api/commit/b99c672112c92f92bf1421f3eae44b6861292de9)) + ## [8.3.0-beta.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.2.2...v8.3.0-beta.1) (2024-11-19)