diff --git a/README.md b/README.md index 06e546e..f4728a9 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,19 @@ val customFeeEstimator = FeeEstimator( probabilities = listOf(0.1, 0.25, 0.5, 0.75, 0.9, 0.99), // Custom block targets - blockTargets = listOf(1.0, 2.0, 3.0, 6.0, 12.0, 24.0, 48.0, 72.0) + blockTargets = listOf(1.0, 2.0, 3.0, 6.0, 12.0, 24.0, 48.0, 72.0), + + // Minimum fee rate in sat/vB (default: 1.0) + // Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. + // Changing this value changes the snapshot bucket layout. Previously persisted snapshots + // are still usable but will have zero weight in the new low-fee buckets until the + // snapshot history fully rolls over (typically 24 hours). + minFeeRate = 0.1, + + // Maximum fee rate in sat/vB for reporting (default: 22027.0) + // Estimates whose fee rate exceeds this bound are returned as null. + // This is an output filter only — the simulation always models the full fee rate space. + maxFeeRate = 1000.0 ) ``` diff --git a/gradle.properties b/gradle.properties index 7457d75..c4430c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ kotlin.build.report.output=file # Maven publish settings GROUP=xyz.block -VERSION_NAME=0.2.2 +VERSION_NAME=0.3.0 POM_NAME=Augur POM_DESCRIPTION=A Bitcoin fee estimation library that provides accurate fee estimates using statistical modeling diff --git a/lib/Module.md b/lib/Module.md index 6692105..ad4c5d5 100644 --- a/lib/Module.md +++ b/lib/Module.md @@ -22,7 +22,7 @@ val feeEstimator = FeeEstimator() // Create a mempool snapshot from current transactions val mempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( - transactions = currentMempoolTransactions.map { + transactions = currentMempoolTransactions.map { MempoolTransaction( weight = it.weight.toLong(), fee = it.baseFee // in satoshis diff --git a/lib/api/lib.api b/lib/api/lib.api index c9ea7e5..ba6425f 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -37,15 +37,19 @@ public final class xyz/block/augur/FeeEstimator { public fun (Ljava/util/List;Ljava/util/List;)V public fun (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;)V public fun (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;)V - public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;D)V + public fun (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;DD)V + public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;DDILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun calculateEstimates (Ljava/util/List;Ljava/lang/Double;)Lxyz/block/augur/FeeEstimate; public static synthetic fun calculateEstimates$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;Ljava/lang/Double;ILjava/lang/Object;)Lxyz/block/augur/FeeEstimate; - public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;)Lxyz/block/augur/FeeEstimator; - public static synthetic fun configure$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;ILjava/lang/Object;)Lxyz/block/augur/FeeEstimator; + public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;Ljava/lang/Double;Ljava/lang/Double;)Lxyz/block/augur/FeeEstimator; + public static synthetic fun configure$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;Ljava/lang/Double;Ljava/lang/Double;ILjava/lang/Object;)Lxyz/block/augur/FeeEstimator; } public final class xyz/block/augur/FeeEstimator$Companion { public final fun getDEFAULT_BLOCK_TARGETS ()Ljava/util/List; + public final fun getDEFAULT_MAX_FEE_RATE ()D + public final fun getDEFAULT_MIN_FEE_RATE ()D public final fun getDEFAULT_PROBABILITIES ()Ljava/util/List; } @@ -91,6 +95,3 @@ public final class xyz/block/augur/MempoolTransaction { public final class xyz/block/augur/MempoolTransaction$Companion { } -public abstract interface annotation class xyz/block/augur/internal/InternalAugurApi : java/lang/annotation/Annotation { -} - diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 2c6688e..64f1620 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -16,9 +16,9 @@ package xyz.block.augur +import xyz.block.augur.internal.BucketLayout import xyz.block.augur.internal.FeeEstimatesCalculator import xyz.block.augur.internal.InflowCalculator -import xyz.block.augur.internal.InternalAugurApi import xyz.block.augur.internal.MempoolSnapshotF64Array import java.time.Duration import java.time.Instant @@ -43,21 +43,38 @@ import java.time.Instant * * @property probabilities The confidence levels to calculate (default: 5%, 20%, 50%, 80%, 95%) * @property blockTargets The block confirmation targets to estimate for (default: 3, 6, 9, 12, 18, 24, 36, 48, 72, 96, 144) + * @property minFeeRate The minimum fee rate in sat/vB for the simulation lower bound (default: 1.0). + * Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Snapshots + * store all bucketed transactions regardless of this value; `minFeeRate` controls which buckets + * are included when the snapshot is converted to the internal simulation array. Transactions whose + * bucket index falls below `ceil(ln(minFeeRate) * 100)` are excluded from the simulation. Note: + * because per-transaction bucketing uses `round()` while the layout boundary uses `ceil()`, a + * transaction at exactly `minFeeRate` may round to a bucket just below the boundary for some + * values; this does not affect the two standard values (1.0 and 0.1) where `ceil` and `round` + * agree. + * @property maxFeeRate The maximum fee rate in sat/vB for reporting (default: 22027.0). + * Fee estimates whose fee rate exceeds this bound are returned as null. This is an output filter + * only — the internal simulation always models the full fee rate space regardless of this value. */ -@OptIn(InternalAugurApi::class) public class FeeEstimator @JvmOverloads public constructor( private val probabilities: List = DEFAULT_PROBABILITIES, private val blockTargets: List = DEFAULT_BLOCK_TARGETS, private val shortTermWindowDuration: Duration = Duration.ofMinutes(30), private val longTermWindowDuration: Duration = Duration.ofHours(24), + private val minFeeRate: Double = DEFAULT_MIN_FEE_RATE, + private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { - private val feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets) + private val bucketLayout: BucketLayout + private val feeEstimatesCalculator: FeeEstimatesCalculator init { require(probabilities.isNotEmpty()) { "At least one probability level must be provided" } require(blockTargets.isNotEmpty()) { "At least one block target must be provided" } require(probabilities.all { it in 0.0..1.0 }) { "All probabilities must be between 0.0 and 1.0" } require(blockTargets.all { it > 0 }) { "All block targets must be positive" } + require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } + bucketLayout = BucketLayout(minFeeRate) + feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate) } /** @@ -83,15 +100,15 @@ public class FeeEstimator @JvmOverloads public constructor( // Sort the snapshots by timestamp to ensure chronological order val orderedSnapshots = mempoolSnapshots.sortedBy { it.timestamp } - val simdSnapshots = orderedSnapshots.map { MempoolSnapshotF64Array.fromMempoolSnapshot(it) } + val simdSnapshots = orderedSnapshots.map { MempoolSnapshotF64Array.fromMempoolSnapshot(it, bucketLayout) } // Extract latest mempool weights and calculate inflow rates val latestMempoolWeights = simdSnapshots.last().buckets - val shortTermInflows = InflowCalculator.calculateInflows(simdSnapshots, shortTermWindowDuration) - val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration) + val shortTermInflows = InflowCalculator.calculateInflows(simdSnapshots, shortTermWindowDuration, bucketLayout) + val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration, bucketLayout) val (calculator, targets) = if (numOfBlocks != null) { - FeeEstimatesCalculator(probabilities, listOf(numOfBlocks)) to listOf(numOfBlocks) + FeeEstimatesCalculator(probabilities, listOf(numOfBlocks), bucketLayout, maxFeeRate) to listOf(numOfBlocks) } else { feeEstimatesCalculator to blockTargets } @@ -112,6 +129,8 @@ public class FeeEstimator @JvmOverloads public constructor( * @param blockTargets New block targets (null to keep current) * @param shortTermWindowDuration New short-term window duration (null to keep current) * @param longTermWindowDuration New long-term window duration (null to keep current) + * @param minFeeRate New minimum fee rate in sat/vB (null to keep current) + * @param maxFeeRate New maximum fee rate in sat/vB (null to keep current) * @return A new [FeeEstimator] instance with the specified settings */ public fun configure( @@ -119,11 +138,15 @@ public class FeeEstimator @JvmOverloads public constructor( blockTargets: List? = null, shortTermWindowDuration: Duration? = null, longTermWindowDuration: Duration? = null, + minFeeRate: Double? = null, + maxFeeRate: Double? = null, ): FeeEstimator = FeeEstimator( probabilities = probabilities ?: this.probabilities, blockTargets = blockTargets ?: this.blockTargets, shortTermWindowDuration = shortTermWindowDuration ?: this.shortTermWindowDuration, longTermWindowDuration = longTermWindowDuration ?: this.longTermWindowDuration, + minFeeRate = minFeeRate ?: this.minFeeRate, + maxFeeRate = maxFeeRate ?: this.maxFeeRate, ) /** @@ -164,5 +187,17 @@ public class FeeEstimator @JvmOverloads public constructor( * Default confidence levels for fee estimation (5%, 20%, 50%, 80%, 95%). */ public val DEFAULT_PROBABILITIES: List = listOf(0.05, 0.20, 0.50, 0.80, 0.95) + + /** + * Default minimum fee rate in sat/vB. Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes. + */ + public val DEFAULT_MIN_FEE_RATE: Double = BucketLayout.DEFAULT_MIN_FEE_RATE + + /** + * Default maximum fee rate in sat/vB for reporting. Estimates above this value are + * returned as null. Rounded up from exp(10) ≈ 22026.47 so that estimates at the + * simulation ceiling (bucket 1000) pass the filter. + */ + public val DEFAULT_MAX_FEE_RATE: Double = FeeEstimatesCalculator.DEFAULT_MAX_FEE_RATE } } diff --git a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt index ec9ef11..d975c73 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -17,7 +17,6 @@ package xyz.block.augur import xyz.block.augur.internal.BucketCreator -import xyz.block.augur.internal.InternalAugurApi import java.time.Instant /** @@ -56,7 +55,6 @@ public data class MempoolSnapshot( * @param timestamp When the snapshot is taken (defaults to now) * @return A new [MempoolSnapshot] instance */ - @OptIn(InternalAugurApi::class) public fun fromMempoolTransactions( transactions: List, blockHeight: Int, diff --git a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt index 4adbca7..50db385 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -17,48 +17,75 @@ package xyz.block.augur.internal import xyz.block.augur.MempoolTransaction +import kotlin.math.ceil import kotlin.math.ln import kotlin.math.min import kotlin.math.round /** - * Utility functions for creating buckets from fee and weight data. + * Internal simulation array layout derived from the minimum fee rate. + * + * The array always extends to a fixed upper bound ([SIMULATION_BUCKET_MAX] = 1000, corresponding + * to ~22026 sat/vB). The user-facing `maxFeeRate` is applied as an output filter in + * [FeeEstimatesCalculator.prepareResultArray], not as an array sizing parameter. + * + * Uses ceil for [bucketMin] so the lowest bucket never represents a fee rate below [minFeeRate]. + * + * @property bucketMin Minimum bucket index, computed as ceil(ln(minFeeRate) * 100) + * @property bucketMax Fixed simulation upper bound (1000) + * @property arraySize Total number of bucket array slots (bucketMax - bucketMin + 1) */ -@InternalAugurApi -internal object BucketCreator { - /** - * Maximum bucket index. - */ - const val BUCKET_MAX = 1000 - - /** - * Minimum bucket index corresponding to 0.1 sat/vByte (Bitcoin Core 29.1/30.0+). - * Calculated as round(ln(0.1) * 100) = -230 - */ - const val BUCKET_MIN = -230 +internal class BucketLayout( + minFeeRate: Double = DEFAULT_MIN_FEE_RATE, +) { + val bucketMin: Int + val bucketMax: Int = SIMULATION_BUCKET_MAX + val arraySize: Int - /** - * Total number of bucket array slots needed to store buckets from BUCKET_MIN to BUCKET_MAX. - * Size = BUCKET_MAX - BUCKET_MIN + 1 = 1000 - (-230) + 1 = 1231 - */ - const val BUCKET_ARRAY_SIZE = BUCKET_MAX - BUCKET_MIN + 1 + init { + require(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } + bucketMin = ceil(ln(minFeeRate) * 100).toInt() + require(bucketMin <= bucketMax) { + "minFeeRate ($minFeeRate) is too high: bucketMin ($bucketMin) exceeds simulation ceiling ($bucketMax)" + } + arraySize = bucketMax - bucketMin + 1 + } /** - * Converts a bucket index (BUCKET_MIN..BUCKET_MAX) to the corresponding array position. - * Buckets are stored in reverse order so that the highest fee rate (BUCKET_MAX) is at index 0. + * Converts a bucket index (bucketMin..bucketMax) to the corresponding array position. + * Buckets are stored in reverse order so that the highest fee rate (bucketMax) is at index 0. */ - fun toArrayIndex(bucket: Int): Int = BUCKET_MAX - bucket + fun toArrayIndex(bucket: Int): Int = bucketMax - bucket /** * Converts an array position back to the original bucket index. */ - fun toBucketIndex(arrayIndex: Int): Int = BUCKET_MAX - arrayIndex + fun toBucketIndex(arrayIndex: Int): Int = bucketMax - arrayIndex + + companion object { + internal const val DEFAULT_MIN_FEE_RATE = 1.0 + + /** + * Fixed simulation upper bound. Corresponds to floor(ln(22027) * 100) = 1000, + * preserving the legacy bucket count. + */ + internal const val SIMULATION_BUCKET_MAX = 1000 + + val DEFAULT = BucketLayout() + } +} +/** + * Utility functions for creating buckets from fee and weight data. + */ +internal object BucketCreator { /** * Creates a bucket map from fee and weight pairs where the key is the bucket index * and the value is the sum of the weights at that fee rate, normalized to a one block duration. */ - fun createFeeRateBuckets(feeRateWeightPairs: List): Map = + fun createFeeRateBuckets( + feeRateWeightPairs: List, + ): Map = feeRateWeightPairs .groupingBy { calculateBucketIndex(it.getFeeRate()) } .fold(0L) { acc, tx -> acc + tx.weight } @@ -66,9 +93,19 @@ internal object BucketCreator { /** * Calculates bucket index using logarithms, providing more precision in the lower fee levels. + * + * Above-max fee rates are clamped to [BucketLayout.SIMULATION_BUCKET_MAX] (the fixed simulation + * ceiling) so their block weight is preserved in the highest bucket. Below-min fee rates are + * intentionally NOT clamped here; they produce indices below [BucketLayout.bucketMin] and are + * dropped by [MempoolSnapshotF64Array.fromMempoolSnapshot], since sub-relay-minimum transactions + * should not influence fee estimates. */ private fun calculateBucketIndex(feeRate: Double): Int = min( + // round() is correct here: each transaction maps to its nearest bucket. + // BucketLayout uses ceil for the lower *boundary* to guarantee the range stays within the + // user's configured min fee rate, but individual transactions should snap to the + // closest discrete bucket rather than being biased up or down. (round(ln(feeRate) * 100).toInt()), - BUCKET_MAX, + BucketLayout.SIMULATION_BUCKET_MAX, ) } diff --git a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt index a4b2c34..7118e85 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -19,9 +19,6 @@ package xyz.block.augur.internal import org.apache.commons.math3.distribution.PoissonDistribution import org.jetbrains.bio.viktor.F64Array import org.jetbrains.bio.viktor.F64Array.Companion.invoke -import xyz.block.augur.internal.BucketCreator.BUCKET_MAX -import xyz.block.augur.internal.BucketCreator.BUCKET_MIN -import kotlin.math.exp import kotlin.math.min import kotlin.math.pow @@ -31,10 +28,11 @@ import kotlin.math.pow * This class simulates the mining of blocks to predict when transactions * with different fee rates would be confirmed. */ -@InternalAugurApi internal class FeeEstimatesCalculator( private val probabilities: List, private val blockTargets: List, + private val bucketLayout: BucketLayout = BucketLayout.DEFAULT, + private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { private val expectedBlocksMined by lazy { getExpectedBlocksMined() } @@ -45,7 +43,7 @@ internal class FeeEstimatesCalculator( * @param shortIntervalInflows Short-term inflow data (typically 30 minutes) * @param longIntervalInflows Long-term inflow data (typically 24 hours) * @return A 2D array of fee estimates where each element corresponds to a specific - * block target and probability level. Values exceeding the max bucket threshold are null. + * block target and probability level. Values exceeding [maxFeeRate] are null. */ fun getFeeEstimates( mempoolSnapshot: F64Array, @@ -174,9 +172,9 @@ internal class FeeEstimatesCalculator( // If index = -1, then no weights are fully mined so can't determine a sufficiently high rate. // Else, createFeeRateBuckets reversed the order, so subtract to recover the original index. return when (index) { - -2 -> BUCKET_MIN // all weights are zero so we can use the cheapest fee rate - -1 -> BUCKET_MAX + 1 // return null - else -> BucketCreator.toBucketIndex(index) + -2 -> bucketLayout.bucketMin // all weights are zero so we can use the cheapest fee rate + -1 -> bucketLayout.bucketMax + 1 // return null + else -> bucketLayout.toBucketIndex(index) } } @@ -209,12 +207,9 @@ internal class FeeEstimatesCalculator( * F64Array can't accommodate nulls so we convert to traditional arrays. */ private fun prepareResultArray(feeRates: F64Array): Array> { - // Maximum allowed fee rate based on the BUCKET_MAX constant - val maxAllowedFeeRate = exp(BUCKET_MAX.toDouble() / 100) - return Array(feeRates.shape[0]) { blockTargetIndex -> Array(feeRates.shape[1]) { probabilityIndex -> - feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxAllowedFeeRate } + feeRates[blockTargetIndex, probabilityIndex].takeIf { it <= maxFeeRate } } } } @@ -263,5 +258,8 @@ internal class FeeEstimatesCalculator( companion object { const val BLOCK_SIZE_WEIGHT_UNITS = 4_000_000 + + // Rounded up from exp(10) ≈ 22026.47 so estimates at the simulation ceiling pass the <= filter + const val DEFAULT_MAX_FEE_RATE = 22027.0 } } diff --git a/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt b/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt index 17fdd6b..9523d38 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt @@ -25,7 +25,6 @@ import java.time.Duration * This is used to simulate new transactions entering the mempool * during the time period being estimated. */ -@InternalAugurApi internal object InflowCalculator { /** * Calculates inflow rates based on historical snapshots. @@ -37,8 +36,9 @@ internal object InflowCalculator { fun calculateInflows( mempoolSnapshots: List, timeframe: Duration, + bucketLayout: BucketLayout = BucketLayout.DEFAULT, ): F64Array { - if (mempoolSnapshots.isEmpty()) return F64Array(BucketCreator.BUCKET_ARRAY_SIZE) + if (mempoolSnapshots.isEmpty()) return F64Array(bucketLayout.arraySize) // First sort the snapshots by timestamp val orderedSnapshots = mempoolSnapshots.sortedBy { it.timestamp } @@ -47,7 +47,7 @@ internal object InflowCalculator { val startTime = endTime - timeframe val relevantSnapshots = orderedSnapshots.filter { it.timestamp in startTime..endTime } - val inflows = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) + val inflows = F64Array(bucketLayout.arraySize) // Group snapshots by block height val snapshotsByBlock = relevantSnapshots.groupBy { it.blockHeight } diff --git a/lib/src/main/kotlin/xyz/block/augur/internal/InternalAugurApi.kt b/lib/src/main/kotlin/xyz/block/augur/internal/InternalAugurApi.kt deleted file mode 100644 index 462c414..0000000 --- a/lib/src/main/kotlin/xyz/block/augur/internal/InternalAugurApi.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025 Block, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package xyz.block.augur.internal - -import kotlin.RequiresOptIn - -/** - * Marks declarations that are internal to the Augur library. - * - * These APIs are not considered stable and may change without notice. - * They should not be used by consumers of the library. - */ -@RequiresOptIn( - level = RequiresOptIn.Level.ERROR, - message = "This API is internal to Augur and should not be used by client code." -) -public annotation class InternalAugurApi diff --git a/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt b/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt index 394dfbc..27861d4 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt @@ -23,7 +23,6 @@ import java.time.Instant /** * Internal representation of mempool snapshot using F64Array for efficient calculations. */ -@InternalAugurApi internal data class MempoolSnapshotF64Array( val timestamp: Instant, val blockHeight: Int, @@ -33,14 +32,22 @@ internal data class MempoolSnapshotF64Array( /** * Converts a [MempoolSnapshot] to [MempoolSnapshotF64Array] for efficient calculations. */ - fun fromMempoolSnapshot(snapshot: MempoolSnapshot): MempoolSnapshotF64Array { - val feeRateBuckets = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) + fun fromMempoolSnapshot( + snapshot: MempoolSnapshot, + bucketLayout: BucketLayout = BucketLayout.DEFAULT, + ): MempoolSnapshotF64Array { + val feeRateBuckets = F64Array(bucketLayout.arraySize) snapshot.bucketedWeights.forEach { (bucket, weight) -> - // Remove buckets below BUCKET_MIN (~0.1 sat/vB; round() admits ~0.0998 here, - // which converts back to ~0.10026 sat/vB so we never emit a sub-0.1 estimate) - if (bucket in BucketCreator.BUCKET_MIN..BucketCreator.BUCKET_MAX) { - // Inserting into reverse order will allow us to mine the highest fee rate buckets first - feeRateBuckets[BucketCreator.toArrayIndex(bucket)] = weight.toDouble() + when { + bucket > bucketLayout.bucketMax -> { + // Fold above simulation ceiling into the highest bucket so their block weight is still counted + feeRateBuckets[0] += weight.toDouble() + } + bucket >= bucketLayout.bucketMin -> { + // Inserting into reverse order will allow us to mine the highest fee rate buckets first + feeRateBuckets[bucketLayout.toArrayIndex(bucket)] += weight.toDouble() + } + // else: below minimum, drop } } diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index a75d7fd..4c0b259 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -21,8 +21,10 @@ import xyz.block.augur.test.TestUtils import java.time.Duration import java.time.Instant import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class FeeEstimatorTest { private val feeEstimator = FeeEstimator() @@ -69,9 +71,8 @@ class FeeEstimatorTest { FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> val feeRate = estimate.getFeeRate(target.toInt(), probability) - assert(feeRate != null && feeRate > 0.0) { - "Fee rate should be positive for target=$target, probability=$probability" - } + assertNotNull(feeRate, "Fee rate should not be null for target=$target, probability=$probability") + assertTrue(feeRate > 0.0, "Fee rate should be positive for target=$target, probability=$probability") } } } @@ -204,9 +205,8 @@ class FeeEstimatorTest { FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> val feeRate = estimate.getFeeRate(target.toInt(), probability) - assert(feeRate != null && feeRate > 0.0) { - "Fee rate should be positive for target=$target, probability=$probability" - } + assertNotNull(feeRate, "Fee rate should not be null for target=$target, probability=$probability") + assertTrue(feeRate > 0.0, "Fee rate should be positive for target=$target, probability=$probability") } } } @@ -328,4 +328,131 @@ class FeeEstimatorTest { feeEstimator.calculateEstimates(snapshots, numOfBlocks = 2.0) } } + + @Test + fun `test constructor throws if minFeeRate is zero or negative`() { + assertFailsWith { + FeeEstimator(minFeeRate = 0.0) + } + assertFailsWith { + FeeEstimator(minFeeRate = -1.0) + } + } + + @Test + fun `test constructor throws if minFeeRate exceeds simulation ceiling`() { + val error = assertFailsWith { + FeeEstimator(minFeeRate = 30000.0) + } + assertTrue(error.message!!.contains("too high"), "Error should mention the upper bound, was: ${error.message}") + } + + @Test + fun `test constructor allows minFeeRate above maxFeeRate since they are independent concerns`() { + // minFeeRate controls simulation array lower bound, maxFeeRate is an output filter. + // A minFeeRate of 5.0 with maxFeeRate of 2.0 means: exclude sub-5 sat/vB transactions + // from the simulation, and filter out any estimates above 2.0 (all estimates would be null). + val estimator = FeeEstimator(minFeeRate = 5.0, maxFeeRate = 2.0) + val snapshots = TestUtils.createSnapshotSequence(blockCount = 5, snapshotsPerBlock = 3) + val estimate = estimator.calculateEstimates(snapshots) + + // All estimates should be null since maxFeeRate is below any possible estimate + FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> + FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> + assertNull( + estimate.getFeeRate(target.toInt(), probability), + "Fee rate should be null when maxFeeRate ($2.0) is below minFeeRate ($5.0) for target=$target, probability=$probability" + ) + } + } + } + + @Test + fun `test constructor throws if maxFeeRate is zero or negative`() { + assertFailsWith { + FeeEstimator(maxFeeRate = 0.0) + } + assertFailsWith { + FeeEstimator(maxFeeRate = -1.0) + } + } + + @Test + fun `test estimates with custom minFeeRate produces valid results`() { + val estimator = FeeEstimator(minFeeRate = 0.1) + val snapshots = TestUtils.createSnapshotSequence(blockCount = 5, snapshotsPerBlock = 3) + val estimate = estimator.calculateEstimates(snapshots) + + FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> + FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> + val feeRate = estimate.getFeeRate(target.toInt(), probability) + assertNotNull(feeRate, "Fee rate should not be null for target=$target, probability=$probability") + assertTrue(feeRate > 0.0, "Fee rate should be positive for target=$target, probability=$probability") + } + } + } + + @Test + fun `test estimates with custom maxFeeRate caps estimates`() { + // Use a low maxFeeRate so some high-confidence estimates may be null + val estimator = FeeEstimator(maxFeeRate = 50.0) + val snapshots = TestUtils.createSnapshotSequence(blockCount = 5, snapshotsPerBlock = 3) + val estimate = estimator.calculateEstimates(snapshots) + + // All non-null estimates should be at or below the max bucket's fee rate + FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> + FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> + val feeRate = estimate.getFeeRate(target.toInt(), probability) + if (feeRate != null) { + assertTrue(feeRate <= 50.0, "Fee rate $feeRate should be <= 50.0 for target=$target, probability=$probability") + } + } + } + } + + @Test + fun `test configure preserves minFeeRate and maxFeeRate`() { + val estimator = FeeEstimator(minFeeRate = 0.1, maxFeeRate = 500.0) + val reconfigured = estimator.configure(probabilities = listOf(0.5, 0.95)) + + val snapshots = TestUtils.createSnapshotSequence(blockCount = 5, snapshotsPerBlock = 3) + val estimate = reconfigured.calculateEstimates(snapshots) + + // Should still work with the preserved fee rate bounds + FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> + val feeRate = estimate.getFeeRate(target.toInt(), 0.5) + if (feeRate != null) { + assertTrue(feeRate <= 500.0, "Fee rate $feeRate should be <= 500.0 after configure") + } + } + } + + @Test + fun `test maxFeeRate is output filter only and does not affect snapshot bucketing`() { + val highFeeTx = MempoolTransaction(weight = 400, fee = 5_000_000) // 50000 sat/vB + + // With default maxFeeRate, the snapshot should clamp to the simulation ceiling (bucket 1000) + val snapshot = MempoolSnapshot.fromMempoolTransactions( + transactions = listOf(highFeeTx), + blockHeight = 1, + ) + val maxBucket = snapshot.bucketedWeights.keys.max() + assertEquals(1000, maxBucket, "High fee rate transactions should be clamped to simulation ceiling") + } + + @Test + fun `test configure can update minFeeRate and maxFeeRate`() { + val estimator = FeeEstimator() + val reconfigured = estimator.configure(minFeeRate = 0.1, maxFeeRate = 100.0) + + val snapshots = TestUtils.createSnapshotSequence(blockCount = 5, snapshotsPerBlock = 3) + val estimate = reconfigured.calculateEstimates(snapshots) + + FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> + val feeRate = estimate.getFeeRate(target.toInt(), 0.5) + if (feeRate != null) { + assertTrue(feeRate <= 100.0, "Fee rate $feeRate should be <= 100.0 after configure with maxFeeRate=100") + } + } + } } diff --git a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt index 7a2043e..fe92cc3 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -18,12 +18,13 @@ package xyz.block.augur.internal import org.junit.jupiter.api.Test import xyz.block.augur.MempoolTransaction +import kotlin.math.exp import kotlin.math.ln import kotlin.math.roundToInt import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue -@OptIn(InternalAugurApi::class) class BucketCreatorTest { @Test fun `test createFeeRateBuckets with single transaction`() { @@ -134,14 +135,41 @@ class BucketCreatorTest { val buckets = BucketCreator.createFeeRateBuckets(transactions) - // The bucket index should be at BUCKET_MAX - assertTrue(buckets.containsKey(BucketCreator.BUCKET_MAX)) - assertEquals(400L, buckets[BucketCreator.BUCKET_MAX]) + // The bucket index should be at the simulation ceiling + assertTrue(buckets.containsKey(BucketLayout.DEFAULT.bucketMax)) + assertEquals(400L, buckets[BucketLayout.DEFAULT.bucketMax]) } @Test - fun `test BUCKET_MIN matches ln(0_1) times 100 rounded`() { - assertEquals((ln(0.1) * 100).roundToInt(), BucketCreator.BUCKET_MIN) + fun `test BucketLayout bucketMin uses ceil so lowest bucket never undershoots minFeeRate`() { + val layout01 = BucketLayout(0.1) + assertEquals(-230, layout01.bucketMin) + + val layout10 = BucketLayout(1.0) + assertEquals(0, layout10.bucketMin) + + // 0.15 sat/vB: round would give -190 (exp(-1.90) ≈ 0.1496, below 0.15) + // ceil gives -189 (exp(-1.89) ≈ 0.1511, above 0.15) + val layout015 = BucketLayout(0.15) + assertEquals(-189, layout015.bucketMin) + assertTrue(exp(layout015.bucketMin.toDouble() / 100) >= 0.15) + } + + @Test + fun `test BucketLayout has fixed bucketMax at simulation ceiling`() { + // bucketMax is always 1000 regardless of construction + assertEquals(1000, BucketLayout.DEFAULT.bucketMax) + assertEquals(1000, BucketLayout(0.1).bucketMax) + assertEquals(1000, BucketLayout(1.0).bucketMax) + assertEquals(1000, BucketLayout(5.0).bucketMax) + } + + @Test + fun `test BucketLayout throws if minFeeRate too high for simulation ceiling`() { + // A minFeeRate that produces bucketMin > 1000 should fail + assertFailsWith { + BucketLayout(minFeeRate = 30000.0) // ln(30000)*100 ≈ 1031 > 1000 + } } @Test diff --git a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt index 6fbf5f2..c8f774c 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt @@ -19,20 +19,19 @@ package xyz.block.augur.internal import org.jetbrains.bio.viktor.F64Array import org.junit.jupiter.api.Test import xyz.block.augur.MempoolSnapshot -import xyz.block.augur.internal.BucketCreator.BUCKET_MAX -import xyz.block.augur.internal.BucketCreator.BUCKET_MIN import java.time.Instant import kotlin.math.exp import kotlin.math.ln import kotlin.math.roundToInt import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -@OptIn(InternalAugurApi::class) class FeeEstimatesCalculatorTest { private val blockTargets = listOf(3.0, 12.0, 144.0) private val probabilities = listOf(0.5, 0.95) + private val defaultLayout = BucketLayout.DEFAULT private val calculator = FeeEstimatesCalculator( @@ -58,13 +57,13 @@ class FeeEstimatesCalculatorTest { @Test fun `test findBestIndex when all weights are mined`() { val weights = F64Array(5) { 0.0 } - assertEquals(BUCKET_MIN, calculator.findBestIndex(weights)) + assertEquals(defaultLayout.bucketMin, calculator.findBestIndex(weights)) } @Test fun `test findBestIndex when no weights are fully mined`() { val weights = F64Array(5) { 1000.0 } - assertEquals(BUCKET_MAX + 1, calculator.findBestIndex(weights)) + assertEquals(defaultLayout.bucketMax + 1, calculator.findBestIndex(weights)) } @Test @@ -76,8 +75,8 @@ class FeeEstimatesCalculatorTest { weights[3] = 1000.0 // unmined weights[4] = 1000.0 // unmined - // Should return BUCKET_MAX - 1 since index 1 is the last fully mined bucket - assertEquals(BUCKET_MAX - 1, calculator.findBestIndex(weights)) + // Should return defaultLayout.bucketMax - 1 since index 1 is the last fully mined bucket + assertEquals(defaultLayout.bucketMax - 1, calculator.findBestIndex(weights)) } @Test @@ -95,7 +94,8 @@ class FeeEstimatesCalculatorTest { ) // With these parameters, we expect some buckets to be fully mined - assert(result != null && result < BUCKET_MAX) + assertNotNull(result) + assertTrue(result < defaultLayout.bucketMax) } @Test @@ -154,7 +154,7 @@ class FeeEstimatesCalculatorTest { weights[3] = 1000.0 weights[4] = 1000.0 - assertEquals(BUCKET_MAX, calculator.findBestIndex(weights)) + assertEquals(defaultLayout.bucketMax, calculator.findBestIndex(weights)) } @Test @@ -172,7 +172,7 @@ class FeeEstimatesCalculatorTest { ) // With such a large block size, all buckets should be mined - assertEquals(BUCKET_MIN, result) + assertEquals(defaultLayout.bucketMin, result) } @Test @@ -193,7 +193,7 @@ class FeeEstimatesCalculatorTest { // Add weights: [4, 8, 12, 12, 12] // After second block: [0, 0, 12, 12, 12] // Last fully mined bucket is index 1 - assertEquals(BUCKET_MAX - 1, result) + assertEquals(defaultLayout.bucketMax - 1, result) } @Test @@ -210,14 +210,17 @@ class FeeEstimatesCalculatorTest { blockSize = 100.0, ) - assertEquals(BUCKET_MIN, result) + assertEquals(defaultLayout.bucketMin, result) } @Test fun `test near-minimum fee bucket never emits sub 0_1 sat per vB`() { + val lowFeeLayout = BucketLayout(0.1) + val lowFeeCalculator = FeeEstimatesCalculator(probabilities, blockTargets, lowFeeLayout) + val nearMinimumFeeRate = 0.0998 val bucketIndex = (ln(nearMinimumFeeRate) * 100).roundToInt() - assertEquals(BUCKET_MIN, bucketIndex) + assertEquals(lowFeeLayout.bucketMin, bucketIndex) val snapshot = MempoolSnapshot( @@ -226,11 +229,11 @@ class FeeEstimatesCalculatorTest { bucketedWeights = mapOf(bucketIndex to 4_000_000L), ) - val mempoolBuckets = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot).buckets - val zeroInflows = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 0.0 } + val mempoolBuckets = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, lowFeeLayout).buckets + val zeroInflows = F64Array(lowFeeLayout.arraySize) { 0.0 } val estimates = - calculator.getFeeEstimates( + lowFeeCalculator.getFeeEstimates( mempoolBuckets, zeroInflows, zeroInflows.copy(), @@ -265,7 +268,7 @@ class FeeEstimatesCalculatorTest { blockSize = 1.0, ) - assertEquals(BUCKET_MAX + 1, result) // Index > BUCKET_MAX, indicating no estimate + assertEquals(defaultLayout.bucketMax + 1, result) // Index > defaultLayout.bucketMax, indicating no estimate } @Test @@ -309,4 +312,83 @@ class FeeEstimatesCalculatorTest { assertEquals(expectedWeightedEstimates, result) } + + @Test + fun `test getFeeEstimates filters estimates above maxFeeRate`() { + // maxFeeRate = 1.5 means only estimates at exp(0/100) = 1.0 sat/vB (bucket 0) pass the filter. + // Estimates at bucket 1 (exp(0.01) ≈ 1.01) and above would be filtered IF they appear. + // We compare results with and without the filter to verify the output filtering. + val calcFiltered = FeeEstimatesCalculator(probabilities, blockTargets, BucketLayout.DEFAULT, maxFeeRate = 1.5) + val calcUnfiltered = FeeEstimatesCalculator(probabilities, blockTargets, BucketLayout.DEFAULT, maxFeeRate = 100000.0) + + // Spread heavy weight across all buckets so the simulation can't mine through them, + // producing estimates at high fee rates that exceed the filter + val weights = F64Array(defaultLayout.arraySize) { 4_000_000.0 } + val zeroInflows = F64Array(defaultLayout.arraySize) { 0.0 } + + val filtered = calcFiltered.getFeeEstimates(weights, zeroInflows, zeroInflows.copy()) + val unfiltered = calcUnfiltered.getFeeEstimates(weights, zeroInflows, zeroInflows.copy()) + + // Verify that the filtered calculator nulls out estimates that exceed maxFeeRate + var foundFiltered = false + for (i in filtered.indices) { + for (j in filtered[i].indices) { + val f = filtered[i][j] + val u = unfiltered[i][j] + if (f == null && u != null) { + // This estimate was filtered because it exceeds maxFeeRate=1.5 + assertTrue(u > 1.5, "Filtered estimate should have been above maxFeeRate") + foundFiltered = true + } + if (f != null) { + assertTrue(f <= 1.5, "Non-null filtered estimate $f should be <= maxFeeRate 1.5") + } + } + } + assertTrue(foundFiltered, "At least one estimate should have been filtered by maxFeeRate") + } + + @Test + fun `test getFeeEstimates preserves estimates at or below maxFeeRate`() { + // Use a maxFeeRate above the simulation ceiling's fee rate + val calc = FeeEstimatesCalculator(probabilities, blockTargets, BucketLayout.DEFAULT, maxFeeRate = 25000.0) + + // Put all weight in bucket 1000 (≈ 22026 sat/vB), which is below 25000 + val weights = F64Array(defaultLayout.arraySize) { 0.0 } + weights[0] = 4_000_000.0 + + val zeroInflows = F64Array(defaultLayout.arraySize) { 0.0 } + val estimates = calc.getFeeEstimates(weights, zeroInflows, zeroInflows.copy()) + + // exp(1000/100) ≈ 22026 < 25000, so estimates should be non-null + estimates.forEach { row -> + row.forEach { fee -> + assertNotNull(fee, "Estimate at or below maxFeeRate should not be null") + } + } + } + + @Test + fun `test getFeeEstimates includes estimate exactly equal to maxFeeRate`() { + // Put all weight in a single bucket so the simulation produces a known fee rate. + // Bucket 0 corresponds to exp(0/100) = 1.0 sat/vB exactly. + val bucketIndex = 0 + val exactFeeRate = kotlin.math.exp(bucketIndex.toDouble() / 100) // 1.0 + + val calc = FeeEstimatesCalculator(probabilities, blockTargets, BucketLayout.DEFAULT, maxFeeRate = exactFeeRate) + + val weights = F64Array(defaultLayout.arraySize) { 0.0 } + weights[defaultLayout.toArrayIndex(bucketIndex)] = 4_000_000.0 + + val zeroInflows = F64Array(defaultLayout.arraySize) { 0.0 } + val estimates = calc.getFeeEstimates(weights, zeroInflows, zeroInflows.copy()) + + // The estimate equals maxFeeRate exactly — the <= filter should preserve it, not null it out + estimates.forEach { row -> + row.forEach { fee -> + assertNotNull(fee, "Estimate exactly equal to maxFeeRate ($exactFeeRate) should not be null") + assertEquals(exactFeeRate, fee, "Estimate should be exactly $exactFeeRate") + } + } + } } diff --git a/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt index 71140b8..84483dd 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt @@ -22,7 +22,6 @@ import java.time.Duration import java.time.Instant import kotlin.test.assertEquals -@OptIn(InternalAugurApi::class) class InflowCalculatorTest { @Test fun `test calculateInflows with empty snapshot list`() { @@ -32,15 +31,15 @@ class InflowCalculatorTest { timeframe = Duration.ofMinutes(10), ) - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(0.0, inflows.sum()) } @Test fun `test calculateInflows with single block snapshots`() { val now = Instant.now() - val buckets1 = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 1000.0 } - val buckets2 = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 2000.0 } + val buckets1 = F64Array(BucketLayout.DEFAULT.arraySize) { 1000.0 } + val buckets2 = F64Array(BucketLayout.DEFAULT.arraySize) { 2000.0 } val snapshots = listOf( @@ -56,7 +55,7 @@ class InflowCalculatorTest { // Each bucket increased by 1000, over 5 minutes // Normalized to 10 minutes, should be 2000 per bucket - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2000.0, inflows[0]) } @@ -64,9 +63,9 @@ class InflowCalculatorTest { fun `test calculateInflows with consistent inflow rate`() { val now = Instant.now() // Create snapshots with consistent inflow rate across all buckets - val buckets1 = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 1_000_000.0 } - val buckets2 = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 2_000_000.0 } - val buckets3 = F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 3_000_000.0 } + val buckets1 = F64Array(BucketLayout.DEFAULT.arraySize) { 1_000_000.0 } + val buckets2 = F64Array(BucketLayout.DEFAULT.arraySize) { 2_000_000.0 } + val buckets3 = F64Array(BucketLayout.DEFAULT.arraySize) { 3_000_000.0 } val snapshots = listOf( @@ -83,9 +82,9 @@ class InflowCalculatorTest { // Each bucket increased by 1M every 5 minutes // Normalized to 10 minutes, should be 2M per bucket - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2_000_000.0, inflows[0]) - assertEquals(2_000_000.0, inflows[BucketCreator.BUCKET_ARRAY_SIZE - 1]) + assertEquals(2_000_000.0, inflows[BucketLayout.DEFAULT.arraySize - 1]) } @Test @@ -94,7 +93,7 @@ class InflowCalculatorTest { // Create snapshots with different inflow rates for different buckets val buckets1 = - F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { idx -> + F64Array(BucketLayout.DEFAULT.arraySize) { idx -> when (idx) { 0 -> 1_000_000.0 1 -> 2_000_000.0 @@ -104,7 +103,7 @@ class InflowCalculatorTest { } val buckets2 = - F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { idx -> + F64Array(BucketLayout.DEFAULT.arraySize) { idx -> when (idx) { 0 -> 2_000_000.0 1 -> 4_000_000.0 @@ -129,7 +128,7 @@ class InflowCalculatorTest { // Bucket 0 increased by 1M -> 2M per 10 minutes // Bucket 1 increased by 2M -> 4M per 10 minutes // Bucket 2 increased by 3M -> 6M per 10 minutes - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2_000_000.0, inflows[0]) assertEquals(4_000_000.0, inflows[1]) assertEquals(6_000_000.0, inflows[2]) @@ -142,9 +141,9 @@ class InflowCalculatorTest { // Create snapshots for the same block height with fluctuating values val snapshots = listOf( - MempoolSnapshotF64Array(now, 100, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 1000.0 }), - MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 500.0 }), // Dip should be ignored - MempoolSnapshotF64Array(now.plusSeconds(300), 100, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 2000.0 }) + MempoolSnapshotF64Array(now, 100, F64Array(BucketLayout.DEFAULT.arraySize) { 1000.0 }), + MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketLayout.DEFAULT.arraySize) { 500.0 }), // Dip should be ignored + MempoolSnapshotF64Array(now.plusSeconds(300), 100, F64Array(BucketLayout.DEFAULT.arraySize) { 2000.0 }) ) val inflows = InflowCalculator.calculateInflows( @@ -154,7 +153,7 @@ class InflowCalculatorTest { // Should only consider the difference between first (1000) and last (2000) snapshots // Over 5 minutes: increased by 1000 -> normalized to 2000 per 10 minutes - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2000.0, inflows[0]) } @@ -164,14 +163,14 @@ class InflowCalculatorTest { val snapshots = listOf( // Block 100 - MempoolSnapshotF64Array(now, 100, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 1000.0 }), - MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 500.0 }), // Should be ignored - MempoolSnapshotF64Array(now.plusSeconds(200), 100, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 2000.0 }), + MempoolSnapshotF64Array(now, 100, F64Array(BucketLayout.DEFAULT.arraySize) { 1000.0 }), + MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketLayout.DEFAULT.arraySize) { 500.0 }), // Should be ignored + MempoolSnapshotF64Array(now.plusSeconds(200), 100, F64Array(BucketLayout.DEFAULT.arraySize) { 2000.0 }), // Block 101 - MempoolSnapshotF64Array(now.plusSeconds(300), 101, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 2000.0 }), - MempoolSnapshotF64Array(now.plusSeconds(400), 101, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 1500.0 }), // Should be ignored - MempoolSnapshotF64Array(now.plusSeconds(500), 101, F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { 3000.0 }) + MempoolSnapshotF64Array(now.plusSeconds(300), 101, F64Array(BucketLayout.DEFAULT.arraySize) { 2000.0 }), + MempoolSnapshotF64Array(now.plusSeconds(400), 101, F64Array(BucketLayout.DEFAULT.arraySize) { 1500.0 }), // Should be ignored + MempoolSnapshotF64Array(now.plusSeconds(500), 101, F64Array(BucketLayout.DEFAULT.arraySize) { 3000.0 }) ) val inflows = InflowCalculator.calculateInflows( @@ -182,7 +181,7 @@ class InflowCalculatorTest { // Block 100: +1000 over 200s // Block 101: +1000 over 200s // Total: +2000 over 400s = +3000 per 600s (10 minutes) - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(3000.0, inflows[0]) } } diff --git a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt index 77a82ea..c55c0fc 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt @@ -24,12 +24,13 @@ import kotlin.math.roundToInt import kotlin.test.assertEquals import kotlin.test.assertTrue -@OptIn(InternalAugurApi::class) class MempoolSnapshotF64ArrayTest { + private val defaultLayout = BucketLayout.DEFAULT + @Test fun `fromMempoolSnapshot drops buckets below minimum`() { - val lowBucket = BucketCreator.BUCKET_MIN - 1 - val validBucket = BucketCreator.BUCKET_MIN + val lowBucket = defaultLayout.bucketMin - 1 + val validBucket = defaultLayout.bucketMin val snapshot = MempoolSnapshot( blockHeight = 100, @@ -42,8 +43,8 @@ class MempoolSnapshotF64ArrayTest { val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, result.buckets.length) - val validIndex = BucketCreator.toArrayIndex(validBucket) + assertEquals(defaultLayout.arraySize, result.buckets.length) + val validIndex = defaultLayout.toArrayIndex(validBucket) assertEquals(600.0, result.buckets[validIndex]) var totalWeight = 0.0 @@ -55,14 +56,41 @@ class MempoolSnapshotF64ArrayTest { } @Test - fun `fromMempoolSnapshot ignores very low fee rates like 0_05 sat per vB`() { - // 0.05 sat/vB is below Bitcoin Core's minimum relay fee (0.1 sat/vB in 29.1/30.0+) - // This should be gracefully ignored, not cause an error + fun `fromMempoolSnapshot with custom minFeeRate accepts lower buckets`() { + val layout = BucketLayout(0.1) + val lowBucket = layout.bucketMin + assertEquals(-230, lowBucket) + val validBucket = 0 // 1 sat/vB + + val snapshot = + MempoolSnapshot( + blockHeight = 100, + timestamp = Instant.now(), + bucketedWeights = mapOf( + lowBucket to 400L, + validBucket to 600L, + ), + ) + + val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, layout) + + assertEquals(layout.arraySize, result.buckets.length) + + var totalWeight = 0.0 + for (i in 0 until result.buckets.length) { + totalWeight += result.buckets[i] + } + assertEquals(1000.0, totalWeight) + } + + @Test + fun `fromMempoolSnapshot ignores very low fee rates below configured minimum`() { + val layout = BucketLayout(0.1) val veryLowFeeRate = 0.05 val veryLowBucket = (ln(veryLowFeeRate) * 100).roundToInt() // -300 - // Verify this bucket is indeed below BUCKET_MIN - assertTrue(veryLowBucket < BucketCreator.BUCKET_MIN) + // Verify this bucket is indeed below the layout's bucketMin + assertTrue(veryLowBucket < layout.bucketMin) val validBucket = 0 // 1 sat/vB @@ -76,7 +104,7 @@ class MempoolSnapshotF64ArrayTest { ), ) - val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) + val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, layout) // Should not throw, and should only include the valid bucket's weight var totalWeight = 0.0 @@ -87,9 +115,10 @@ class MempoolSnapshotF64ArrayTest { } @Test - fun `fromMempoolSnapshot drops buckets above BUCKET_MAX`() { - val oversizedBucket = BucketCreator.BUCKET_MAX + 1 - val validBucket = BucketCreator.BUCKET_MAX + fun `fromMempoolSnapshot folds above-max buckets into highest bucket`() { + val oversizedBucket = defaultLayout.bucketMax + 1 + // Use a bucket below bucketMax so the folded weight and valid weight land in different slots + val validBucket = defaultLayout.bucketMax - 1 val snapshot = MempoolSnapshot( @@ -103,12 +132,15 @@ class MempoolSnapshotF64ArrayTest { val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) - // Only the valid bucket's weight should be included + // Above-max weight is folded into the highest bucket (index 0 = bucketMax) + assertEquals(1000.0, result.buckets[0]) + // Valid bucket is in its own slot + assertEquals(500.0, result.buckets[defaultLayout.toArrayIndex(validBucket)]) + // Total weight is preserved var totalWeight = 0.0 for (i in 0 until result.buckets.length) { totalWeight += result.buckets[i] } - assertEquals(500.0, totalWeight) - assertEquals(500.0, result.buckets[BucketCreator.toArrayIndex(validBucket)]) + assertEquals(1500.0, totalWeight) } }