Skip to content

Commit 396e2ea

Browse files
authored
feat: consider tenure block fullness for transaction fee estimations (#2203)
* feat: consider average tenure block fullness for tx fee estimations * chore: continue * test: first behavior * test: new structure * test: all scenarios * chore: add global flag * chore: node info * fix: lazy load tenure costs from core * chore: merge develop
1 parent 77bd2f8 commit 396e2ea

File tree

6 files changed

+517
-44
lines changed

6 files changed

+517
-44
lines changed

.env

+21
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,27 @@ STACKS_CORE_RPC_PORT=20443
8888
# STACKS_FAUCET_NODE_HOST=<IP or hostname>
8989
# STACKS_FAUCET_NODE_PORT=<port number>
9090

91+
# Enables the enhanced transaction fee estimator that will alter results for `POST
92+
# /v2/fees/transaction`.
93+
# STACKS_CORE_FEE_ESTIMATOR_ENABLED=0
94+
95+
# Multiplier for all fee estimations returned by Stacks core. Must be between 0.0 and 1.0.
96+
# STACKS_CORE_FEE_ESTIMATION_MODIFIER=1.0
97+
98+
# How many past tenures the fee estimator will look at to determine if there is a fee market for
99+
# transactions.
100+
# STACKS_CORE_FEE_PAST_TENURE_FULLNESS_WINDOW=5
101+
102+
# Percentage at which past tenure cost dimensions will be considered "full".
103+
# STACKS_CORE_FEE_PAST_DIMENSION_FULLNESS_THRESHOLD=0.9
104+
105+
# Percentage at which current cost tenures will be considered "busy" in order to determine if we
106+
# should check previous tenures for a fee market.
107+
# STACKS_CORE_FEE_CURRENT_DIMENSION_FULLNESS_THRESHOLD=0.5
108+
109+
# Minimum number of blocks the current tenure must have in order to check for "busyness".
110+
# STACKS_CORE_FEE_CURRENT_BLOCK_COUNT_MINIMUM=5
111+
91112
# A comma-separated list of STX private keys which will send faucet transactions to accounts that
92113
# request them. Attempts will always be made from the first account, only once transaction chaining
93114
# gets too long the faucet will start using the next one.

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

+165-15
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { FastifyPluginAsync } from 'fastify';
77
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
88
import { Server, ServerResponse } from 'node:http';
99
import { fastifyHttpProxy } from '@fastify/http-proxy';
10+
import { StacksCoreRpcClient } from '../../core-rpc/client';
11+
import { parseBoolean } from '@hirosystems/api-toolkit';
1012

1113
function GetStacksNodeProxyEndpoint() {
1214
// Use STACKS_CORE_PROXY env vars if available, otherwise fallback to `STACKS_CORE_RPC
@@ -21,8 +23,32 @@ function getReqUrl(req: { url: string; hostname: string }): URL {
2123
return new URL(req.url, `http://${req.hostname}`);
2224
}
2325

26+
function parseFloatEnv(env: string) {
27+
const envValue = process.env[env];
28+
if (envValue) {
29+
const parsed = parseFloat(envValue);
30+
if (!isNaN(parsed) && parsed > 0) {
31+
return parsed;
32+
}
33+
}
34+
}
35+
2436
// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/chainstate/stacks/db/blocks.rs#L338
2537
const MINIMUM_TX_FEE_RATE_PER_BYTE = 1;
38+
// https://github.com/stacks-network/stacks-core/blob/eb865279406d0700474748dc77df100cba6fa98e/stackslib/src/core/mod.rs#L212-L218
39+
const DEFAULT_BLOCK_LIMIT_WRITE_LENGTH = 15_000_000;
40+
const DEFAULT_BLOCK_LIMIT_WRITE_COUNT = 15_000;
41+
const DEFAULT_BLOCK_LIMIT_READ_LENGTH = 100_000_000;
42+
const DEFAULT_BLOCK_LIMIT_READ_COUNT = 15_000;
43+
const DEFAULT_BLOCK_LIMIT_RUNTIME = 5_000_000_000;
44+
// https://github.com/stacks-network/stacks-core/blob/9c8ed7b9df51a0b5d96135cb594843091311b20e/stackslib/src/chainstate/stacks/mod.rs#L1096
45+
const BLOCK_LIMIT_SIZE = 2 * 1024 * 1024;
46+
47+
const DEFAULT_FEE_ESTIMATION_MODIFIER = 1.0;
48+
const DEFAULT_FEE_PAST_TENURE_FULLNESS_WINDOW = 5;
49+
const DEFAULT_FEE_PAST_DIMENSION_FULLNESS_THRESHOLD = 0.9;
50+
const DEFAULT_FEE_CURRENT_DIMENSION_FULLNESS_THRESHOLD = 0.5;
51+
const DEFAULT_FEE_CURRENT_BLOCK_COUNT_MINIMUM = 5;
2652

2753
interface FeeEstimation {
2854
fee: number;
@@ -41,6 +67,21 @@ interface FeeEstimateResponse {
4167
estimations: [FeeEstimation, FeeEstimation, FeeEstimation];
4268
}
4369

70+
interface FeeEstimateProxyOptions {
71+
estimationModifier: number;
72+
pastTenureFullnessWindow: number;
73+
pastDimensionFullnessThreshold: number;
74+
currentDimensionFullnessThreshold: number;
75+
currentBlockCountMinimum: number;
76+
readCountLimit: number;
77+
readLengthLimit: number;
78+
writeCountLimit: number;
79+
writeLengthLimit: number;
80+
runtimeLimit: number;
81+
sizeLimit: number;
82+
minTxFeeRatePerByte: number;
83+
}
84+
4485
export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
4586
Record<never, never>,
4687
Server,
@@ -50,6 +91,24 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
5091

5192
logger.info(`/v2/* proxying to: ${stacksNodeRpcEndpoint}`);
5293

94+
// Default fee estimator options
95+
let feeEstimatorEnabled = false;
96+
let didReadTenureCostsFromCore = false;
97+
const feeOpts: FeeEstimateProxyOptions = {
98+
estimationModifier: DEFAULT_FEE_ESTIMATION_MODIFIER,
99+
pastTenureFullnessWindow: DEFAULT_FEE_PAST_TENURE_FULLNESS_WINDOW,
100+
pastDimensionFullnessThreshold: DEFAULT_FEE_PAST_DIMENSION_FULLNESS_THRESHOLD,
101+
currentDimensionFullnessThreshold: DEFAULT_FEE_CURRENT_DIMENSION_FULLNESS_THRESHOLD,
102+
currentBlockCountMinimum: DEFAULT_FEE_CURRENT_BLOCK_COUNT_MINIMUM,
103+
readCountLimit: DEFAULT_BLOCK_LIMIT_READ_COUNT,
104+
readLengthLimit: DEFAULT_BLOCK_LIMIT_READ_LENGTH,
105+
writeCountLimit: DEFAULT_BLOCK_LIMIT_WRITE_COUNT,
106+
writeLengthLimit: DEFAULT_BLOCK_LIMIT_WRITE_LENGTH,
107+
runtimeLimit: DEFAULT_BLOCK_LIMIT_RUNTIME,
108+
sizeLimit: BLOCK_LIMIT_SIZE,
109+
minTxFeeRatePerByte: MINIMUM_TX_FEE_RATE_PER_BYTE,
110+
};
111+
53112
/**
54113
* Check for any extra endpoints that have been configured for performing a "multicast" for a tx submission.
55114
*/
@@ -128,6 +187,73 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
128187
}
129188
}
130189

190+
/// Retrieves the current Stacks tenure cost limits from the active PoX epoch.
191+
async function readEpochTenureCostLimits(): Promise<void> {
192+
const clientInfo = stacksNodeRpcEndpoint.split(':');
193+
const client = new StacksCoreRpcClient({ host: clientInfo[0], port: clientInfo[1] });
194+
let attempts = 0;
195+
while (attempts < 5) {
196+
try {
197+
const poxData = await client.getPox();
198+
const epochLimits = poxData.epochs.pop()?.block_limit;
199+
if (epochLimits) {
200+
feeOpts.readCountLimit = epochLimits.read_count;
201+
feeOpts.readLengthLimit = epochLimits.read_length;
202+
feeOpts.writeCountLimit = epochLimits.write_count;
203+
feeOpts.writeLengthLimit = epochLimits.write_length;
204+
feeOpts.runtimeLimit = epochLimits.runtime;
205+
}
206+
logger.info(`CoreNodeRpcProxy successfully retrieved tenure cost limits from core`);
207+
return;
208+
} catch (error) {
209+
logger.warn(error, `CoreNodeRpcProxy unable to get current tenure cost limits`);
210+
attempts++;
211+
}
212+
}
213+
logger.warn(
214+
`CoreNodeRpcProxy failed to get tenure cost limits after ${attempts} attempts. Using defaults.`
215+
);
216+
}
217+
218+
/// Checks if we should modify all transaction fee estimations to always use the minimum fee. This
219+
/// only happens if there is no fee market i.e. if the last N block tenures have not been full. We
220+
/// use a threshold to determine if a block size dimension is full.
221+
async function shouldUseTransactionMinimumFee(): Promise<boolean> {
222+
return await fastify.db.sqlTransaction(async sql => {
223+
// Check current tenure first. If it's empty after a few blocks, go back to minimum fee.
224+
const currThreshold = feeOpts.currentDimensionFullnessThreshold;
225+
const currentCosts = await fastify.db.getCurrentTenureExecutionCosts(sql);
226+
if (
227+
currentCosts.block_count >= feeOpts.currentBlockCountMinimum &&
228+
currentCosts.read_count < feeOpts.readCountLimit * currThreshold &&
229+
currentCosts.read_length < feeOpts.readLengthLimit * currThreshold &&
230+
currentCosts.write_count < feeOpts.writeCountLimit * currThreshold &&
231+
currentCosts.write_length < feeOpts.writeLengthLimit * currThreshold &&
232+
currentCosts.runtime < feeOpts.runtimeLimit * currThreshold &&
233+
currentCosts.tx_total_size < feeOpts.sizeLimit * currThreshold
234+
) {
235+
return true;
236+
}
237+
238+
// Current tenure is either full-ish or it has just begun. Take a look at past averages. If
239+
// they are below our past threshold, go to min fee.
240+
const pastThreshold = feeOpts.pastDimensionFullnessThreshold;
241+
const pastCosts = await fastify.db.getLastTenureWeightedAverageExecutionCosts(
242+
sql,
243+
feeOpts.pastTenureFullnessWindow
244+
);
245+
if (!pastCosts) return true;
246+
return (
247+
pastCosts.read_count < feeOpts.readCountLimit * pastThreshold &&
248+
pastCosts.read_length < feeOpts.readLengthLimit * pastThreshold &&
249+
pastCosts.write_count < feeOpts.writeCountLimit * pastThreshold &&
250+
pastCosts.write_length < feeOpts.writeLengthLimit * pastThreshold &&
251+
pastCosts.runtime < feeOpts.runtimeLimit * pastThreshold &&
252+
pastCosts.tx_total_size < feeOpts.sizeLimit * pastThreshold
253+
);
254+
});
255+
}
256+
131257
const maxBodySize = 10_000_000; // 10 MB max POST body size
132258
fastify.addContentTypeParser(
133259
'application/octet-stream',
@@ -137,15 +263,24 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
137263
}
138264
);
139265

140-
let feeEstimationModifier: number | null = null;
141266
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-
}
267+
feeEstimatorEnabled = parseBoolean(process.env['STACKS_CORE_FEE_ESTIMATOR_ENABLED']);
268+
if (!feeEstimatorEnabled) return;
269+
270+
feeOpts.estimationModifier =
271+
parseFloatEnv('STACKS_CORE_FEE_ESTIMATION_MODIFIER') ?? feeOpts.estimationModifier;
272+
feeOpts.pastTenureFullnessWindow =
273+
parseFloatEnv('STACKS_CORE_FEE_PAST_TENURE_FULLNESS_WINDOW') ??
274+
feeOpts.pastTenureFullnessWindow;
275+
feeOpts.pastDimensionFullnessThreshold =
276+
parseFloatEnv('STACKS_CORE_FEE_PAST_DIMENSION_FULLNESS_THRESHOLD') ??
277+
feeOpts.pastDimensionFullnessThreshold;
278+
feeOpts.currentDimensionFullnessThreshold =
279+
parseFloatEnv('STACKS_CORE_FEE_CURRENT_DIMENSION_FULLNESS_THRESHOLD') ??
280+
feeOpts.currentDimensionFullnessThreshold;
281+
feeOpts.currentBlockCountMinimum =
282+
parseFloatEnv('STACKS_CORE_FEE_CURRENT_BLOCK_COUNT_MINIMUM') ??
283+
feeOpts.currentBlockCountMinimum;
149284
});
150285

151286
await fastify.register(fastifyHttpProxy, {
@@ -236,8 +371,12 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
236371
} else if (
237372
getReqUrl(req).pathname === '/v2/fees/transaction' &&
238373
reply.statusCode === 200 &&
239-
feeEstimationModifier !== null
374+
feeEstimatorEnabled
240375
) {
376+
if (!didReadTenureCostsFromCore) {
377+
await readEpochTenureCostLimits();
378+
didReadTenureCostsFromCore = true;
379+
}
241380
const reqBody = req.body as {
242381
estimated_len?: number;
243382
transaction_payload: string;
@@ -247,14 +386,25 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
247386
reqBody.estimated_len ?? 0,
248387
reqBody.transaction_payload.length / 2
249388
);
250-
const minFee = txSize * MINIMUM_TX_FEE_RATE_PER_BYTE;
251-
const modifier = feeEstimationModifier;
389+
const minFee = txSize * feeOpts.minTxFeeRatePerByte;
252390
const responseBuffer = await readRequestBody(response as ServerResponse);
253391
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-
});
392+
393+
if (await shouldUseTransactionMinimumFee()) {
394+
responseJson.estimations.forEach(estimation => {
395+
estimation.fee = minFee;
396+
});
397+
} else {
398+
// Fall back to Stacks core's estimate, but modify it according to the ENV configured
399+
// multiplier.
400+
responseJson.estimations.forEach(estimation => {
401+
// max(min fee, estimate returned by node * configurable modifier)
402+
estimation.fee = Math.max(
403+
minFee,
404+
Math.round(estimation.fee * feeOpts.estimationModifier)
405+
);
406+
});
407+
}
258408
await reply.removeHeader('content-length').send(JSON.stringify(responseJson));
259409
} else {
260410
await reply.send(response);

src/core-rpc/client.ts

+13
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ export interface CoreRpcPoxInfo {
5959
blocks_until_reward_phase: number;
6060
ustx_until_pox_rejection: number;
6161
};
62+
epochs: {
63+
epoch_id: string;
64+
start_height: number;
65+
end_height: number;
66+
block_limit: {
67+
write_length: number;
68+
write_count: number;
69+
read_length: number;
70+
read_count: number;
71+
runtime: number;
72+
};
73+
network_epoch: number;
74+
}[];
6275

6376
/** @deprecated included for backwards-compatibility */
6477
min_amount_ustx: number;

0 commit comments

Comments
 (0)