Skip to content

Commit a9be8ca

Browse files
authored
Merge pull request #2218 from hirosystems/beta
release 8.6.0
2 parents 2f5f887 + f124151 commit a9be8ca

22 files changed

+660
-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.

CHANGELOG.md

+20
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
1+
## [8.6.0-beta.2](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.6.0-beta.1...v8.6.0-beta.2) (2025-02-06)
2+
3+
4+
### Bug Fixes
5+
6+
* use an independent sql connection for mempool stats ([#2217](https://github.com/hirosystems/stacks-blockchain-api/issues/2217)) ([f8137e4](https://github.com/hirosystems/stacks-blockchain-api/commit/f8137e477db98eaf4045d6372f5825502cdd547f))
7+
8+
## [8.6.0-beta.1](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.5.0...v8.6.0-beta.1) (2025-01-28)
9+
10+
11+
### Features
12+
13+
* consider tenure block fullness for transaction fee estimations ([#2203](https://github.com/hirosystems/stacks-blockchain-api/issues/2203)) ([396e2ea](https://github.com/hirosystems/stacks-blockchain-api/commit/396e2ea09f02a832205b67a5d595ab7f9ab4c579))
14+
* store total transaction size in blocks table ([#2204](https://github.com/hirosystems/stacks-blockchain-api/issues/2204)) ([ac7c41b](https://github.com/hirosystems/stacks-blockchain-api/commit/ac7c41b86d7ead4f534a407f22f5680014ea0db0))
15+
16+
17+
### Bug Fixes
18+
19+
* make tx_total_size column nullable ([#2207](https://github.com/hirosystems/stacks-blockchain-api/issues/2207)) ([77bd2f8](https://github.com/hirosystems/stacks-blockchain-api/commit/77bd2f884b3ad1537af917e951e1d6609df90d3c))
20+
121
## [8.5.0](https://github.com/hirosystems/stacks-blockchain-api/compare/v8.4.0...v8.5.0) (2025-01-20)
222

323

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* eslint-disable camelcase */
2+
3+
exports.shorthands = undefined;
4+
5+
exports.up = pgm => {
6+
pgm.addColumn('blocks', {
7+
tx_total_size: {
8+
type: 'int',
9+
},
10+
});
11+
};
12+
13+
exports.down = pgm => {
14+
pgm.dropColumn('blocks', ['tx_total_size']);
15+
};

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;

src/datastore/common.ts

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface DbBlock {
2222
execution_cost_runtime: number;
2323
execution_cost_write_count: number;
2424
execution_cost_write_length: number;
25+
tx_total_size: number | null;
2526
tx_count: number;
2627
block_time: number;
2728
signer_bitvec: string | null;
@@ -862,6 +863,7 @@ export interface BlockQueryResult {
862863
execution_cost_runtime: string;
863864
execution_cost_write_count: string;
864865
execution_cost_write_length: string;
866+
tx_total_size: number | null;
865867
tx_count: number;
866868
signer_bitvec: string | null;
867869
tenure_height: number | null;
@@ -1287,6 +1289,7 @@ export interface BlockInsertValues {
12871289
execution_cost_runtime: number;
12881290
execution_cost_write_count: number;
12891291
execution_cost_write_length: number;
1292+
tx_total_size: number | null;
12901293
tx_count: number;
12911294
signer_bitvec: string | null;
12921295
signer_signatures: PgBytea[] | null;

src/datastore/helpers.ts

+2
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export const BLOCK_COLUMNS = [
185185
'execution_cost_write_count',
186186
'execution_cost_write_length',
187187
'tx_count',
188+
'tx_total_size',
188189
'signer_bitvec',
189190
'tenure_height',
190191
];
@@ -485,6 +486,7 @@ export function parseBlockQueryResult(row: BlockQueryResult): DbBlock {
485486
execution_cost_runtime: Number.parseInt(row.execution_cost_runtime),
486487
execution_cost_write_count: Number.parseInt(row.execution_cost_write_count),
487488
execution_cost_write_length: Number.parseInt(row.execution_cost_write_length),
489+
tx_total_size: row.tx_total_size,
488490
tx_count: row.tx_count,
489491
signer_bitvec: row.signer_bitvec,
490492
signer_signatures: null, // this field is not queried from db by default due to size constraints

0 commit comments

Comments
 (0)