Skip to content

Commit 9b7e97d

Browse files
authored
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
1 parent c75e9fb commit 9b7e97d

File tree

4 files changed

+179
-9
lines changed

4 files changed

+179
-9
lines changed

package-lock.json

+33-9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@
147147
"strict-event-emitter-types": "2.0.0",
148148
"tiny-secp256k1": "2.2.1",
149149
"ts-unused-exports": "7.0.3",
150+
"undici": "6.21.0",
150151
"uuid": "8.3.2",
151152
"ws": "7.5.10",
152153
"zone-file": "2.0.0-beta.3"

src/api/routes/core-node-rpc-proxy.ts

+55
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ function getReqUrl(req: { url: string; hostname: string }): URL {
2121
return new URL(req.url, `http://${req.hostname}`);
2222
}
2323

24+
// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/chainstate/stacks/db/blocks.rs#L338
25+
const MINIMUM_TX_FEE_RATE_PER_BYTE = 1;
26+
27+
interface FeeEstimation {
28+
fee: number;
29+
fee_rate: number;
30+
}
31+
interface FeeEstimateResponse {
32+
cost_scalar_change_by_byte: number;
33+
estimated_cost: {
34+
read_count: number;
35+
read_length: number;
36+
runtime: number;
37+
write_count: number;
38+
write_length: number;
39+
};
40+
estimated_cost_scalar: number;
41+
estimations: [FeeEstimation, FeeEstimation, FeeEstimation];
42+
}
43+
2444
export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
2545
Record<never, never>,
2646
Server,
@@ -117,10 +137,22 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
117137
}
118138
);
119139

140+
let feeEstimationModifier: number | null = null;
141+
fastify.addHook('onReady', () => {
142+
const feeEstEnvVar = process.env['STACKS_CORE_FEE_ESTIMATION_MODIFIER'];
143+
if (feeEstEnvVar) {
144+
const parsed = parseFloat(feeEstEnvVar);
145+
if (!isNaN(parsed) && parsed > 0) {
146+
feeEstimationModifier = parsed;
147+
}
148+
}
149+
});
150+
120151
await fastify.register(fastifyHttpProxy, {
121152
upstream: `http://${stacksNodeRpcEndpoint}`,
122153
rewritePrefix: '/v2',
123154
http2: false,
155+
globalAgent: true,
124156
preValidation: async (req, reply) => {
125157
if (getReqUrl(req).pathname !== '/v2/transactions') {
126158
return;
@@ -201,6 +233,29 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
201233
const txId = responseBuffer.toString();
202234
await logTxBroadcast(txId);
203235
await reply.send(responseBuffer);
236+
} else if (
237+
getReqUrl(req).pathname === '/v2/fees/transaction' &&
238+
reply.statusCode === 200 &&
239+
feeEstimationModifier !== null
240+
) {
241+
const reqBody = req.body as {
242+
estimated_len?: number;
243+
transaction_payload: string;
244+
};
245+
// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/net/api/postfeerate.rs#L200-L201
246+
const txSize = Math.max(
247+
reqBody.estimated_len ?? 0,
248+
reqBody.transaction_payload.length / 2
249+
);
250+
const minFee = txSize * MINIMUM_TX_FEE_RATE_PER_BYTE;
251+
const modifier = feeEstimationModifier;
252+
const responseBuffer = await readRequestBody(response as ServerResponse);
253+
const responseJson = JSON.parse(responseBuffer.toString()) as FeeEstimateResponse;
254+
responseJson.estimations.forEach(estimation => {
255+
// max(min fee, estimate returned by node * configurable modifier)
256+
estimation.fee = Math.max(minFee, Math.round(estimation.fee * modifier));
257+
});
258+
await reply.removeHeader('content-length').send(JSON.stringify(responseJson));
204259
} else {
205260
await reply.send(response);
206261
}

tests/api/v2-proxy.test.ts

+90
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as nock from 'nock';
99
import { DbBlock } from '../../src/datastore/common';
1010
import { PgWriteStore } from '../../src/datastore/pg-write-store';
1111
import { migrate } from '../utils/test-helpers';
12+
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
1213

1314
describe('v2-proxy tests', () => {
1415
let db: PgWriteStore;
@@ -27,6 +28,95 @@ describe('v2-proxy tests', () => {
2728
await migrate('down');
2829
});
2930

31+
test('tx fee estimation', async () => {
32+
const primaryProxyEndpoint = 'proxy-stacks-node:12345';
33+
const feeEstimationModifier = 0.5;
34+
await useWithCleanup(
35+
() => {
36+
const restoreEnvVars = withEnvVars(
37+
['STACKS_CORE_FEE_ESTIMATION_MODIFIER', feeEstimationModifier.toString()],
38+
['STACKS_CORE_PROXY_HOST', primaryProxyEndpoint.split(':')[0]],
39+
['STACKS_CORE_PROXY_PORT', primaryProxyEndpoint.split(':')[1]]
40+
);
41+
return [, () => restoreEnvVars()] as const;
42+
},
43+
() => {
44+
const agent = new MockAgent();
45+
const originalAgent = getGlobalDispatcher();
46+
setGlobalDispatcher(agent);
47+
return [agent, () => setGlobalDispatcher(originalAgent)] as const;
48+
},
49+
async () => {
50+
const apiServer = await startApiServer({
51+
datastore: db,
52+
chainId: ChainID.Mainnet,
53+
});
54+
return [apiServer, apiServer.terminate] as const;
55+
},
56+
async (_, mockAgent, api) => {
57+
const primaryStubbedResponse = {
58+
cost_scalar_change_by_byte: 0.00476837158203125,
59+
estimated_cost: {
60+
read_count: 19,
61+
read_length: 4814,
62+
runtime: 7175000,
63+
write_count: 2,
64+
write_length: 1020,
65+
},
66+
estimated_cost_scalar: 14,
67+
estimations: [
68+
{
69+
fee: 400,
70+
fee_rate: 1.2410714285714286,
71+
},
72+
{
73+
fee: 800,
74+
fee_rate: 8.958333333333332,
75+
},
76+
{
77+
fee: 1000,
78+
fee_rate: 10,
79+
},
80+
],
81+
};
82+
const testRequest = {
83+
estimated_len: 350,
84+
transaction_payload:
85+
'021af942874ce525e87f21bbe8c121b12fac831d02f4086765742d696e666f0b7570646174652d696e666f00000000',
86+
};
87+
88+
mockAgent
89+
.get(`http://${primaryProxyEndpoint}`)
90+
.intercept({
91+
path: '/v2/fees/transaction',
92+
method: 'POST',
93+
})
94+
.reply(200, JSON.stringify(primaryStubbedResponse), {
95+
headers: { 'Content-Type': 'application/json' },
96+
});
97+
98+
const postTxReq = await supertest(api.server)
99+
.post(`/v2/fees/transaction`)
100+
.set('Content-Type', 'application/json')
101+
.send(JSON.stringify(testRequest));
102+
expect(postTxReq.status).toBe(200);
103+
// Expected min fee is the byte size because MINIMUM_TX_FEE_RATE_PER_BYTE=1
104+
const expectedMinFee = Math.max(
105+
testRequest.estimated_len ?? 0,
106+
testRequest.transaction_payload.length / 2
107+
);
108+
const expectedResponse = {
109+
...primaryStubbedResponse,
110+
};
111+
expectedResponse.estimations = expectedResponse.estimations.map(est => ({
112+
...est,
113+
fee: Math.max(expectedMinFee, Math.round(est.fee * feeEstimationModifier)),
114+
}));
115+
expect(postTxReq.body).toEqual(expectedResponse);
116+
}
117+
);
118+
});
119+
30120
test('tx post multicast', async () => {
31121
const primaryProxyEndpoint = 'proxy-stacks-node:12345';
32122
const extraTxEndpoint = 'http://extra-tx-endpoint-a/test';

0 commit comments

Comments
 (0)