@@ -7,6 +7,8 @@ import { FastifyPluginAsync } from 'fastify';
77import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' ;
88import { Server , ServerResponse } from 'node:http' ;
99import { fastifyHttpProxy } from '@fastify/http-proxy' ;
10+ import { StacksCoreRpcClient } from '../../core-rpc/client' ;
11+ import { parseBoolean } from '@hirosystems/api-toolkit' ;
1012
1113function 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
2537const 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
2753interface 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+
4485export 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 ) ;
0 commit comments