From 9b7e97d8d3faf94b1d17162cb2bf17f214afe172 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 19 Nov 2024 18:46:03 +0100 Subject: [PATCH] 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';