@@ -7,6 +7,8 @@ import { FastifyPluginAsync } from 'fastify';
7
7
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' ;
8
8
import { Server , ServerResponse } from 'node:http' ;
9
9
import { fastifyHttpProxy } from '@fastify/http-proxy' ;
10
+ import { StacksCoreRpcClient } from '../../core-rpc/client' ;
11
+ import { parseBoolean } from '@hirosystems/api-toolkit' ;
10
12
11
13
function GetStacksNodeProxyEndpoint ( ) {
12
14
// 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 {
21
23
return new URL ( req . url , `http://${ req . hostname } ` ) ;
22
24
}
23
25
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
+
24
36
// https://github.com/stacks-network/stacks-core/blob/20d5137438c7d169ea97dd2b6a4d51b8374a4751/stackslib/src/chainstate/stacks/db/blocks.rs#L338
25
37
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 ;
26
52
27
53
interface FeeEstimation {
28
54
fee : number ;
@@ -41,6 +67,21 @@ interface FeeEstimateResponse {
41
67
estimations : [ FeeEstimation , FeeEstimation , FeeEstimation ] ;
42
68
}
43
69
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
+
44
85
export const CoreNodeRpcProxyRouter : FastifyPluginAsync <
45
86
Record < never , never > ,
46
87
Server ,
@@ -50,6 +91,24 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
50
91
51
92
logger . info ( `/v2/* proxying to: ${ stacksNodeRpcEndpoint } ` ) ;
52
93
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
+
53
112
/**
54
113
* Check for any extra endpoints that have been configured for performing a "multicast" for a tx submission.
55
114
*/
@@ -128,6 +187,73 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
128
187
}
129
188
}
130
189
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
+
131
257
const maxBodySize = 10_000_000 ; // 10 MB max POST body size
132
258
fastify . addContentTypeParser (
133
259
'application/octet-stream' ,
@@ -137,15 +263,24 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
137
263
}
138
264
) ;
139
265
140
- let feeEstimationModifier : number | null = null ;
141
266
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 ;
149
284
} ) ;
150
285
151
286
await fastify . register ( fastifyHttpProxy , {
@@ -236,8 +371,12 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
236
371
} else if (
237
372
getReqUrl ( req ) . pathname === '/v2/fees/transaction' &&
238
373
reply . statusCode === 200 &&
239
- feeEstimationModifier !== null
374
+ feeEstimatorEnabled
240
375
) {
376
+ if ( ! didReadTenureCostsFromCore ) {
377
+ await readEpochTenureCostLimits ( ) ;
378
+ didReadTenureCostsFromCore = true ;
379
+ }
241
380
const reqBody = req . body as {
242
381
estimated_len ?: number ;
243
382
transaction_payload : string ;
@@ -247,14 +386,25 @@ export const CoreNodeRpcProxyRouter: FastifyPluginAsync<
247
386
reqBody . estimated_len ?? 0 ,
248
387
reqBody . transaction_payload . length / 2
249
388
) ;
250
- const minFee = txSize * MINIMUM_TX_FEE_RATE_PER_BYTE ;
251
- const modifier = feeEstimationModifier ;
389
+ const minFee = txSize * feeOpts . minTxFeeRatePerByte ;
252
390
const responseBuffer = await readRequestBody ( response as ServerResponse ) ;
253
391
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
+ }
258
408
await reply . removeHeader ( 'content-length' ) . send ( JSON . stringify ( responseJson ) ) ;
259
409
} else {
260
410
await reply . send ( response ) ;
0 commit comments