Skip to content

Commit

Permalink
feat: option to modify estimated fees in /v2/fees/transaction proxy (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
zone117x authored Nov 19, 2024
1 parent c75e9fb commit 9b7e97d
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 9 deletions.
42 changes: 33 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
55 changes: 55 additions & 0 deletions src/api/routes/core-node-rpc-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never, never>,
Server,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
90 changes: 90 additions & 0 deletions tests/api/v2-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand Down

0 comments on commit 9b7e97d

Please sign in to comment.