From 19f76acd4c331a4316fdec6d3fc85fd65924517a Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Mon, 23 Mar 2026 17:57:04 -0700 Subject: [PATCH 01/15] Allow users to configure minimum fee rate via FeeEstimator(minFeeRate) Introduces BucketConfig to replace the hardcoded BUCKET_MIN constant, letting library consumers set their own minimum fee rate floor. The default is 1.0 sat/vB; users on Bitcoin Core 29.1+ can opt in to sub-1 sat/vB support with FeeEstimator(minFeeRate = 0.1). Bumps version to 0.3.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 ++- gradle.properties | 2 +- .../kotlin/xyz/block/augur/FeeEstimator.kt | 23 ++++++++-- .../xyz/block/augur/internal/BucketCreator.kt | 30 ++++++------ .../augur/internal/FeeEstimatesCalculator.kt | 4 +- .../block/augur/internal/InflowCalculator.kt | 5 +- .../augur/internal/MempoolSnapshotF64Array.kt | 12 +++-- .../block/augur/internal/BucketCreatorTest.kt | 8 +++- .../internal/FeeEstimatesCalculatorTest.kt | 19 ++++---- .../augur/internal/InflowCalculatorTest.kt | 46 +++++++++---------- .../internal/MempoolSnapshotF64ArrayTest.kt | 46 +++++++++++++++---- 11 files changed, 130 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 06e546e..2086ead 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,11 @@ 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 + minFeeRate = 0.1 ) ``` 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/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 2c6688e..e9095ee 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -16,6 +16,7 @@ package xyz.block.augur +import xyz.block.augur.internal.BucketConfig import xyz.block.augur.internal.FeeEstimatesCalculator import xyz.block.augur.internal.InflowCalculator import xyz.block.augur.internal.InternalAugurApi @@ -43,6 +44,8 @@ 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 to consider (default: 1.0). Set to 0.1 for + * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. */ @OptIn(InternalAugurApi::class) public class FeeEstimator @JvmOverloads public constructor( @@ -50,14 +53,17 @@ public class FeeEstimator @JvmOverloads public constructor( 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 feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets) + private val bucketConfig = BucketConfig(minFeeRate) + private val feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketConfig) 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(minFeeRate > 0.0) { "minFeeRate must be positive" } } /** @@ -83,15 +89,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, bucketConfig) } // 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, bucketConfig) + val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration, bucketConfig) val (calculator, targets) = if (numOfBlocks != null) { - FeeEstimatesCalculator(probabilities, listOf(numOfBlocks)) to listOf(numOfBlocks) + FeeEstimatesCalculator(probabilities, listOf(numOfBlocks), bucketConfig) to listOf(numOfBlocks) } else { feeEstimatesCalculator to blockTargets } @@ -119,11 +125,13 @@ public class FeeEstimator @JvmOverloads public constructor( blockTargets: List? = null, shortTermWindowDuration: Duration? = null, longTermWindowDuration: Duration? = null, + minFeeRate: Double? = null, ): FeeEstimator = FeeEstimator( probabilities = probabilities ?: this.probabilities, blockTargets = blockTargets ?: this.blockTargets, shortTermWindowDuration = shortTermWindowDuration ?: this.shortTermWindowDuration, longTermWindowDuration = longTermWindowDuration ?: this.longTermWindowDuration, + minFeeRate = minFeeRate ?: this.minFeeRate, ) /** @@ -164,5 +172,10 @@ 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 const val DEFAULT_MIN_FEE_RATE: Double = 1.0 } } 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..e54dbc7 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -21,6 +21,22 @@ import kotlin.math.ln import kotlin.math.min import kotlin.math.round +/** + * Holds bucket boundaries derived from a minimum fee rate. + * + * @property bucketMin Minimum bucket index, computed as round(ln(minFeeRate) * 100) + * @property arraySize Total number of bucket array slots (BUCKET_MAX - bucketMin + 1) + */ +@InternalAugurApi +internal class BucketConfig(minFeeRate: Double) { + val bucketMin: Int = round(ln(minFeeRate) * 100).toInt() + val arraySize: Int = BucketCreator.BUCKET_MAX - bucketMin + 1 + + companion object { + val DEFAULT = BucketConfig(1.0) + } +} + /** * Utility functions for creating buckets from fee and weight data. */ @@ -32,19 +48,7 @@ internal object BucketCreator { 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 - - /** - * 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 - - /** - * Converts a bucket index (BUCKET_MIN..BUCKET_MAX) to the corresponding array position. + * Converts a bucket index to the corresponding array position. * Buckets are stored in reverse order so that the highest fee rate (BUCKET_MAX) is at index 0. */ fun toArrayIndex(bucket: Int): Int = BUCKET_MAX - bucket 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..627cc95 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -20,7 +20,6 @@ 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 @@ -35,6 +34,7 @@ import kotlin.math.pow internal class FeeEstimatesCalculator( private val probabilities: List, private val blockTargets: List, + private val bucketConfig: BucketConfig = BucketConfig.DEFAULT, ) { private val expectedBlocksMined by lazy { getExpectedBlocksMined() } @@ -174,7 +174,7 @@ 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 + -2 -> bucketConfig.bucketMin // all weights are zero so we can use the cheapest fee rate -1 -> BUCKET_MAX + 1 // return null else -> BucketCreator.toBucketIndex(index) } 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..cb780e3 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt @@ -37,8 +37,9 @@ internal object InflowCalculator { fun calculateInflows( mempoolSnapshots: List, timeframe: Duration, + bucketConfig: BucketConfig = BucketConfig.DEFAULT, ): F64Array { - if (mempoolSnapshots.isEmpty()) return F64Array(BucketCreator.BUCKET_ARRAY_SIZE) + if (mempoolSnapshots.isEmpty()) return F64Array(bucketConfig.arraySize) // First sort the snapshots by timestamp val orderedSnapshots = mempoolSnapshots.sortedBy { it.timestamp } @@ -47,7 +48,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(bucketConfig.arraySize) // Group snapshots by block height val snapshotsByBlock = relevantSnapshots.groupBy { it.blockHeight } 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..40bcde1 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt @@ -33,12 +33,14 @@ 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, + bucketConfig: BucketConfig = BucketConfig.DEFAULT, + ): MempoolSnapshotF64Array { + val feeRateBuckets = F64Array(bucketConfig.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) { + // Remove buckets outside the configured range + if (bucket in bucketConfig.bucketMin..BucketCreator.BUCKET_MAX) { // Inserting into reverse order will allow us to mine the highest fee rate buckets first feeRateBuckets[BucketCreator.toArrayIndex(bucket)] = weight.toDouble() } 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..590d438 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -140,8 +140,12 @@ class BucketCreatorTest { } @Test - fun `test BUCKET_MIN matches ln(0_1) times 100 rounded`() { - assertEquals((ln(0.1) * 100).roundToInt(), BucketCreator.BUCKET_MIN) + fun `test BucketConfig bucketMin matches ln of minFeeRate times 100 rounded`() { + val config01 = BucketConfig(0.1) + assertEquals((ln(0.1) * 100).roundToInt(), config01.bucketMin) + + val config10 = BucketConfig(1.0) + assertEquals(0, config10.bucketMin) } @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..69afbf4 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt @@ -20,7 +20,6 @@ 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 @@ -33,6 +32,7 @@ import kotlin.test.assertTrue class FeeEstimatesCalculatorTest { private val blockTargets = listOf(3.0, 12.0, 144.0) private val probabilities = listOf(0.5, 0.95) + private val defaultConfig = BucketConfig.DEFAULT private val calculator = FeeEstimatesCalculator( @@ -58,7 +58,7 @@ class FeeEstimatesCalculatorTest { @Test fun `test findBestIndex when all weights are mined`() { val weights = F64Array(5) { 0.0 } - assertEquals(BUCKET_MIN, calculator.findBestIndex(weights)) + assertEquals(defaultConfig.bucketMin, 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(defaultConfig.bucketMin, result) } @Test @@ -210,14 +210,17 @@ class FeeEstimatesCalculatorTest { blockSize = 100.0, ) - assertEquals(BUCKET_MIN, result) + assertEquals(defaultConfig.bucketMin, result) } @Test fun `test near-minimum fee bucket never emits sub 0_1 sat per vB`() { + val lowFeeConfig = BucketConfig(0.1) + val lowFeeCalculator = FeeEstimatesCalculator(probabilities, blockTargets, lowFeeConfig) + val nearMinimumFeeRate = 0.0998 val bucketIndex = (ln(nearMinimumFeeRate) * 100).roundToInt() - assertEquals(BUCKET_MIN, bucketIndex) + assertEquals(lowFeeConfig.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, lowFeeConfig).buckets + val zeroInflows = F64Array(lowFeeConfig.arraySize) { 0.0 } val estimates = - calculator.getFeeEstimates( + lowFeeCalculator.getFeeEstimates( mempoolBuckets, zeroInflows, zeroInflows.copy(), 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..40e064c 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt @@ -32,15 +32,15 @@ class InflowCalculatorTest { timeframe = Duration.ofMinutes(10), ) - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, inflows.length) + assertEquals(BucketConfig.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(BucketConfig.DEFAULT.arraySize) { 1000.0 } + val buckets2 = F64Array(BucketConfig.DEFAULT.arraySize) { 2000.0 } val snapshots = listOf( @@ -56,7 +56,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(BucketConfig.DEFAULT.arraySize, inflows.length) assertEquals(2000.0, inflows[0]) } @@ -64,9 +64,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(BucketConfig.DEFAULT.arraySize) { 1_000_000.0 } + val buckets2 = F64Array(BucketConfig.DEFAULT.arraySize) { 2_000_000.0 } + val buckets3 = F64Array(BucketConfig.DEFAULT.arraySize) { 3_000_000.0 } val snapshots = listOf( @@ -83,9 +83,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(BucketConfig.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[BucketConfig.DEFAULT.arraySize - 1]) } @Test @@ -94,7 +94,7 @@ class InflowCalculatorTest { // Create snapshots with different inflow rates for different buckets val buckets1 = - F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { idx -> + F64Array(BucketConfig.DEFAULT.arraySize) { idx -> when (idx) { 0 -> 1_000_000.0 1 -> 2_000_000.0 @@ -104,7 +104,7 @@ class InflowCalculatorTest { } val buckets2 = - F64Array(BucketCreator.BUCKET_ARRAY_SIZE) { idx -> + F64Array(BucketConfig.DEFAULT.arraySize) { idx -> when (idx) { 0 -> 2_000_000.0 1 -> 4_000_000.0 @@ -129,7 +129,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(BucketConfig.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 +142,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(BucketConfig.DEFAULT.arraySize) { 1000.0 }), + MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 500.0 }), // Dip should be ignored + MempoolSnapshotF64Array(now.plusSeconds(300), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 2000.0 }) ) val inflows = InflowCalculator.calculateInflows( @@ -154,7 +154,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(BucketConfig.DEFAULT.arraySize, inflows.length) assertEquals(2000.0, inflows[0]) } @@ -164,14 +164,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(BucketConfig.DEFAULT.arraySize) { 1000.0 }), + MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 500.0 }), // Should be ignored + MempoolSnapshotF64Array(now.plusSeconds(200), 100, F64Array(BucketConfig.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(BucketConfig.DEFAULT.arraySize) { 2000.0 }), + MempoolSnapshotF64Array(now.plusSeconds(400), 101, F64Array(BucketConfig.DEFAULT.arraySize) { 1500.0 }), // Should be ignored + MempoolSnapshotF64Array(now.plusSeconds(500), 101, F64Array(BucketConfig.DEFAULT.arraySize) { 3000.0 }) ) val inflows = InflowCalculator.calculateInflows( @@ -182,7 +182,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(BucketConfig.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..3c46531 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt @@ -26,10 +26,12 @@ import kotlin.test.assertTrue @OptIn(InternalAugurApi::class) class MempoolSnapshotF64ArrayTest { + private val defaultConfig = BucketConfig.DEFAULT + @Test fun `fromMempoolSnapshot drops buckets below minimum`() { - val lowBucket = BucketCreator.BUCKET_MIN - 1 - val validBucket = BucketCreator.BUCKET_MIN + val lowBucket = defaultConfig.bucketMin - 1 + val validBucket = defaultConfig.bucketMin val snapshot = MempoolSnapshot( blockHeight = 100, @@ -42,7 +44,7 @@ class MempoolSnapshotF64ArrayTest { val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) - assertEquals(BucketCreator.BUCKET_ARRAY_SIZE, result.buckets.length) + assertEquals(defaultConfig.arraySize, result.buckets.length) val validIndex = BucketCreator.toArrayIndex(validBucket) assertEquals(600.0, result.buckets[validIndex]) @@ -55,14 +57,40 @@ 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 config = BucketConfig(0.1) + val lowBucket = (ln(0.1) * 100).roundToInt() // -230, valid for this config + 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, config) + + assertEquals(config.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 config = BucketConfig(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 config's bucketMin + assertTrue(veryLowBucket < config.bucketMin) val validBucket = 0 // 1 sat/vB @@ -76,7 +104,7 @@ class MempoolSnapshotF64ArrayTest { ), ) - val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) + val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, config) // Should not throw, and should only include the valid bucket's weight var totalWeight = 0.0 From 4dbc12e377f16ee52b7eb2b064549201ba55215d Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Mon, 23 Mar 2026 18:03:46 -0700 Subject: [PATCH 02/15] Guard minFeeRate bounds and use ceil to prevent undershooting floor - Add upper bound validation: minFeeRate must be <= 22026 sat/vB (beyond which bucketMin exceeds BUCKET_MAX and array size goes negative) - Use ceil instead of round in BucketConfig so the lowest bucket never represents a fee rate below the user's configured minimum - Add tests for both edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/kotlin/xyz/block/augur/FeeEstimator.kt | 9 +++++++++ .../xyz/block/augur/internal/BucketCreator.kt | 7 +++++-- .../kotlin/xyz/block/augur/FeeEstimatorTest.kt | 17 +++++++++++++++++ .../block/augur/internal/BucketCreatorTest.kt | 12 ++++++++++-- .../internal/MempoolSnapshotF64ArrayTest.kt | 3 ++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index e9095ee..440624d 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -64,6 +64,9 @@ public class FeeEstimator @JvmOverloads public constructor( 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(minFeeRate > 0.0) { "minFeeRate must be positive" } + require(minFeeRate <= MAX_MIN_FEE_RATE) { + "minFeeRate must be <= $MAX_MIN_FEE_RATE sat/vB" + } } /** @@ -177,5 +180,11 @@ public class FeeEstimator @JvmOverloads public constructor( * Default minimum fee rate in sat/vB. Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes. */ public const val DEFAULT_MIN_FEE_RATE: Double = 1.0 + + /** + * Maximum allowed value for minFeeRate. Values above this would produce a bucket index + * exceeding BUCKET_MAX, resulting in a non-positive array size. + */ + internal const val MAX_MIN_FEE_RATE: Double = 22026.0 } } 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 e54dbc7..2a67849 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -17,6 +17,7 @@ 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 @@ -24,12 +25,14 @@ import kotlin.math.round /** * Holds bucket boundaries derived from a minimum fee rate. * - * @property bucketMin Minimum bucket index, computed as round(ln(minFeeRate) * 100) + * Uses ceil so the lowest bucket never represents a fee rate below [minFeeRate]. + * + * @property bucketMin Minimum bucket index, computed as ceil(ln(minFeeRate) * 100) * @property arraySize Total number of bucket array slots (BUCKET_MAX - bucketMin + 1) */ @InternalAugurApi internal class BucketConfig(minFeeRate: Double) { - val bucketMin: Int = round(ln(minFeeRate) * 100).toInt() + val bucketMin: Int = ceil(ln(minFeeRate) * 100).toInt() val arraySize: Int = BucketCreator.BUCKET_MAX - bucketMin + 1 companion object { diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index a75d7fd..24079f1 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -328,4 +328,21 @@ 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 maximum`() { + assertFailsWith { + FeeEstimator(minFeeRate = 30000.0) + } + } } 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 590d438..b3adfba 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -18,6 +18,8 @@ package xyz.block.augur.internal import org.junit.jupiter.api.Test import xyz.block.augur.MempoolTransaction +import kotlin.math.ceil +import kotlin.math.exp import kotlin.math.ln import kotlin.math.roundToInt import kotlin.test.assertEquals @@ -140,12 +142,18 @@ class BucketCreatorTest { } @Test - fun `test BucketConfig bucketMin matches ln of minFeeRate times 100 rounded`() { + fun `test BucketConfig bucketMin uses ceil so lowest bucket never undershoots minFeeRate`() { val config01 = BucketConfig(0.1) - assertEquals((ln(0.1) * 100).roundToInt(), config01.bucketMin) + assertEquals(-230, config01.bucketMin) val config10 = BucketConfig(1.0) assertEquals(0, config10.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 config015 = BucketConfig(0.15) + assertEquals(-189, config015.bucketMin) + assertTrue(exp(config015.bucketMin.toDouble() / 100) >= 0.15) } @Test 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 3c46531..b108285 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt @@ -59,7 +59,8 @@ class MempoolSnapshotF64ArrayTest { @Test fun `fromMempoolSnapshot with custom minFeeRate accepts lower buckets`() { val config = BucketConfig(0.1) - val lowBucket = (ln(0.1) * 100).roundToInt() // -230, valid for this config + val lowBucket = config.bucketMin + assertEquals(-230, lowBucket) val validBucket = 0 // 1 sat/vB val snapshot = From 8ce8fe22a238cb035a353518fc0ca33b5d081236 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Mon, 23 Mar 2026 18:20:48 -0700 Subject: [PATCH 03/15] Make maximum fee rate configurable via FeeEstimator(maxFeeRate) Moves BUCKET_MAX from a hardcoded constant into BucketConfig alongside bucketMin. The toArrayIndex/toBucketIndex helpers now live on BucketConfig since they depend on the configured max. Transactions with fee rates above maxFeeRate are folded into the highest bucket so their block weight is still counted in simulations, while fee estimates above the max are returned as null. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- .../kotlin/xyz/block/augur/FeeEstimator.kt | 18 ++-- .../xyz/block/augur/internal/BucketCreator.kt | 60 +++++++------ .../augur/internal/FeeEstimatesCalculator.kt | 11 ++- .../augur/internal/MempoolSnapshotF64Array.kt | 14 ++- .../xyz/block/augur/FeeEstimatorTest.kt | 87 ++++++++++++++++++- .../block/augur/internal/BucketCreatorTest.kt | 23 ++++- .../internal/FeeEstimatesCalculatorTest.kt | 39 +++++++-- .../internal/MempoolSnapshotF64ArrayTest.kt | 48 ++++++++-- 9 files changed, 242 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 2086ead..36d636b 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,11 @@ val customFeeEstimator = FeeEstimator( // 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 - minFeeRate = 0.1 + minFeeRate = 0.1, + + // Maximum fee rate in sat/vB (default: 22026.0) + // Estimates above this rate are returned as null + maxFeeRate = 1000.0 ) ``` diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 440624d..baac61c 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -46,6 +46,9 @@ import java.time.Instant * @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 to consider (default: 1.0). Set to 0.1 for * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. + * @property maxFeeRate The maximum fee rate in sat/vB to consider (default: 22026.0). + * Fee estimates above this rate are returned as null; transactions above this rate + * are still counted as block weight in the highest bucket. */ @OptIn(InternalAugurApi::class) public class FeeEstimator @JvmOverloads public constructor( @@ -54,8 +57,9 @@ public class FeeEstimator @JvmOverloads public constructor( 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 bucketConfig = BucketConfig(minFeeRate) + private val bucketConfig = BucketConfig(minFeeRate, maxFeeRate) private val feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketConfig) init { @@ -64,9 +68,8 @@ public class FeeEstimator @JvmOverloads public constructor( 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(minFeeRate > 0.0) { "minFeeRate must be positive" } - require(minFeeRate <= MAX_MIN_FEE_RATE) { - "minFeeRate must be <= $MAX_MIN_FEE_RATE sat/vB" - } + require(maxFeeRate > 0.0) { "maxFeeRate must be positive" } + require(minFeeRate < maxFeeRate) { "minFeeRate must be less than maxFeeRate" } } /** @@ -129,12 +132,14 @@ public class FeeEstimator @JvmOverloads public constructor( 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, ) /** @@ -182,9 +187,8 @@ public class FeeEstimator @JvmOverloads public constructor( public const val DEFAULT_MIN_FEE_RATE: Double = 1.0 /** - * Maximum allowed value for minFeeRate. Values above this would produce a bucket index - * exceeding BUCKET_MAX, resulting in a non-positive array size. + * Default maximum fee rate in sat/vB (~exp(10)). Fee estimates above this are returned as null. */ - internal const val MAX_MIN_FEE_RATE: Double = 22026.0 + public const val DEFAULT_MAX_FEE_RATE: Double = 22027.0 } } 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 2a67849..eecdc12 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -18,25 +18,46 @@ package xyz.block.augur.internal import xyz.block.augur.MempoolTransaction import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.ln import kotlin.math.min import kotlin.math.round /** - * Holds bucket boundaries derived from a minimum fee rate. + * Holds bucket boundaries derived from minimum and maximum fee rates. * - * Uses ceil so the lowest bucket never represents a fee rate below [minFeeRate]. + * Uses ceil for [bucketMin] so the lowest bucket never represents a fee rate below [minFeeRate]. + * Uses floor for [bucketMax] so the highest bucket never represents a fee rate above [maxFeeRate]. * * @property bucketMin Minimum bucket index, computed as ceil(ln(minFeeRate) * 100) - * @property arraySize Total number of bucket array slots (BUCKET_MAX - bucketMin + 1) + * @property bucketMax Maximum bucket index, computed as floor(ln(maxFeeRate) * 100) + * @property arraySize Total number of bucket array slots (bucketMax - bucketMin + 1) */ @InternalAugurApi -internal class BucketConfig(minFeeRate: Double) { +internal class BucketConfig( + minFeeRate: Double = DEFAULT_MIN_FEE_RATE, + maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, +) { val bucketMin: Int = ceil(ln(minFeeRate) * 100).toInt() - val arraySize: Int = BucketCreator.BUCKET_MAX - bucketMin + 1 + val bucketMax: Int = floor(ln(maxFeeRate) * 100).toInt() + val arraySize: Int = bucketMax - bucketMin + 1 + + /** + * 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 = bucketMax - bucket + + /** + * Converts an array position back to the original bucket index. + */ + fun toBucketIndex(arrayIndex: Int): Int = bucketMax - arrayIndex companion object { - val DEFAULT = BucketConfig(1.0) + internal const val DEFAULT_MIN_FEE_RATE = 1.0 + internal const val DEFAULT_MAX_FEE_RATE = 22027.0 // > exp(10) ≈ 22026.47, so floor gives bucket 1000 + + val DEFAULT = BucketConfig() } } @@ -45,37 +66,24 @@ internal class BucketConfig(minFeeRate: Double) { */ @InternalAugurApi internal object BucketCreator { - /** - * Maximum bucket index. - */ - const val BUCKET_MAX = 1000 - - /** - * Converts a bucket index to the corresponding array position. - * Buckets are stored in reverse order so that the highest fee rate (BUCKET_MAX) is at index 0. - */ - fun toArrayIndex(bucket: Int): Int = BUCKET_MAX - bucket - - /** - * Converts an array position back to the original bucket index. - */ - fun toBucketIndex(arrayIndex: Int): Int = BUCKET_MAX - arrayIndex - /** * 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, + bucketConfig: BucketConfig = BucketConfig.DEFAULT, + ): Map = feeRateWeightPairs - .groupingBy { calculateBucketIndex(it.getFeeRate()) } + .groupingBy { calculateBucketIndex(it.getFeeRate(), bucketConfig) } .fold(0L) { acc, tx -> acc + tx.weight } .toSortedMap() /** * Calculates bucket index using logarithms, providing more precision in the lower fee levels. */ - private fun calculateBucketIndex(feeRate: Double): Int = min( + private fun calculateBucketIndex(feeRate: Double, bucketConfig: BucketConfig): Int = min( (round(ln(feeRate) * 100).toInt()), - BUCKET_MAX, + bucketConfig.bucketMax, ) } 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 627cc95..c72e1a4 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -19,7 +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 kotlin.math.exp import kotlin.math.min import kotlin.math.pow @@ -175,8 +174,8 @@ internal class FeeEstimatesCalculator( // Else, createFeeRateBuckets reversed the order, so subtract to recover the original index. return when (index) { -2 -> bucketConfig.bucketMin // all weights are zero so we can use the cheapest fee rate - -1 -> BUCKET_MAX + 1 // return null - else -> BucketCreator.toBucketIndex(index) + -1 -> bucketConfig.bucketMax + 1 // return null + else -> bucketConfig.toBucketIndex(index) } } @@ -209,12 +208,12 @@ 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) + // Maximum allowed fee rate based on the configured bucketMax + val maxAllowedFeeRate = exp(bucketConfig.bucketMax.toDouble() / 100) return Array(feeRates.shape[0]) { blockTargetIndex -> Array(feeRates.shape[1]) { probabilityIndex -> - feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxAllowedFeeRate } + feeRates[blockTargetIndex, probabilityIndex].takeIf { it <= maxAllowedFeeRate } } } } 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 40bcde1..2248741 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt @@ -39,10 +39,16 @@ internal data class MempoolSnapshotF64Array( ): MempoolSnapshotF64Array { val feeRateBuckets = F64Array(bucketConfig.arraySize) snapshot.bucketedWeights.forEach { (bucket, weight) -> - // Remove buckets outside the configured range - if (bucket in bucketConfig.bucketMin..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 > bucketConfig.bucketMax -> { + // Fold above-max into the highest bucket so their block weight is still counted + feeRateBuckets[0] += weight.toDouble() + } + bucket >= bucketConfig.bucketMin -> { + // Inserting into reverse order will allow us to mine the highest fee rate buckets first + feeRateBuckets[bucketConfig.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 24079f1..d1c9022 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -340,9 +340,92 @@ class FeeEstimatorTest { } @Test - fun `test constructor throws if minFeeRate exceeds maximum`() { + fun `test constructor throws if minFeeRate exceeds maxFeeRate`() { assertFailsWith { - FeeEstimator(minFeeRate = 30000.0) + FeeEstimator(minFeeRate = 100.0, maxFeeRate = 50.0) + } + } + + @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) + assert(feeRate != null && 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) { + assert(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) { + assert(feeRate <= 500.0) { + "Fee rate $feeRate should be <= 500.0 after configure" + } + } + } + } + + @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) { + assert(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 b3adfba..e169789 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -18,7 +18,6 @@ package xyz.block.augur.internal import org.junit.jupiter.api.Test import xyz.block.augur.MempoolTransaction -import kotlin.math.ceil import kotlin.math.exp import kotlin.math.ln import kotlin.math.roundToInt @@ -137,8 +136,8 @@ 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]) + assertTrue(buckets.containsKey(BucketConfig.DEFAULT.bucketMax)) + assertEquals(400L, buckets[BucketConfig.DEFAULT.bucketMax]) } @Test @@ -156,6 +155,24 @@ class BucketCreatorTest { assertTrue(exp(config015.bucketMin.toDouble() / 100) >= 0.15) } + @Test + fun `test BucketConfig bucketMax uses floor so highest bucket never overshoots maxFeeRate`() { + val configDefault = BucketConfig.DEFAULT + assertEquals(1000, configDefault.bucketMax) + + // 1000 sat/vB: ln(1000) * 100 = 690.77..., floor = 690 + // exp(690/100) = exp(6.90) ≈ 992.27, which is <= 1000 + val config1000 = BucketConfig(maxFeeRate = 1000.0) + assertEquals(690, config1000.bucketMax) + assertTrue(exp(config1000.bucketMax.toDouble() / 100) <= 1000.0) + + // 500 sat/vB: ln(500) * 100 = 621.46..., floor = 621 + // exp(621/100) = exp(6.21) ≈ 496.58, which is <= 500 + val config500 = BucketConfig(maxFeeRate = 500.0) + assertEquals(621, config500.bucketMax) + assertTrue(exp(config500.bucketMax.toDouble() / 100) <= 500.0) + } + @Test fun `test createFeeRateBuckets with very low fee rates`() { // Test 0.1 sat/vB minimum (Bitcoin Core 29.1/30.0+) 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 69afbf4..172c6b5 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt @@ -19,12 +19,12 @@ 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 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 @@ -64,7 +64,7 @@ class FeeEstimatesCalculatorTest { @Test fun `test findBestIndex when no weights are fully mined`() { val weights = F64Array(5) { 1000.0 } - assertEquals(BUCKET_MAX + 1, calculator.findBestIndex(weights)) + assertEquals(defaultConfig.bucketMax + 1, calculator.findBestIndex(weights)) } @Test @@ -76,8 +76,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 defaultConfig.bucketMax - 1 since index 1 is the last fully mined bucket + assertEquals(defaultConfig.bucketMax - 1, calculator.findBestIndex(weights)) } @Test @@ -95,7 +95,7 @@ class FeeEstimatesCalculatorTest { ) // With these parameters, we expect some buckets to be fully mined - assert(result != null && result < BUCKET_MAX) + assert(result != null && result < defaultConfig.bucketMax) } @Test @@ -154,7 +154,7 @@ class FeeEstimatesCalculatorTest { weights[3] = 1000.0 weights[4] = 1000.0 - assertEquals(BUCKET_MAX, calculator.findBestIndex(weights)) + assertEquals(defaultConfig.bucketMax, calculator.findBestIndex(weights)) } @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(defaultConfig.bucketMax - 1, result) } @Test @@ -268,7 +268,7 @@ class FeeEstimatesCalculatorTest { blockSize = 1.0, ) - assertEquals(BUCKET_MAX + 1, result) // Index > BUCKET_MAX, indicating no estimate + assertEquals(defaultConfig.bucketMax + 1, result) // Index > defaultConfig.bucketMax, indicating no estimate } @Test @@ -312,4 +312,27 @@ class FeeEstimatesCalculatorTest { assertEquals(expectedWeightedEstimates, result) } + + @Test + fun `test getFeeEstimates includes estimate exactly at bucketMax fee rate`() { + // Create a config with a low maxFeeRate so we can easily hit the boundary + val config = BucketConfig(maxFeeRate = 10.0) // bucketMax = floor(ln(10)*100) = 230 + val calc = FeeEstimatesCalculator(probabilities, blockTargets, config) + + // Put all weight in a single bucket at bucketMax — after mining, the estimate + // should land exactly at exp(bucketMax/100) which must NOT be null + val weights = F64Array(config.arraySize) { 0.0 } + weights[0] = 4_000_000.0 // highest bucket (bucketMax) + + val zeroInflows = F64Array(config.arraySize) { 0.0 } + val estimates = calc.getFeeEstimates(weights, zeroInflows, zeroInflows.copy()) + + // The estimate for the highest bucket should be exp(230/100) ≈ 9.97 + // which is <= maxFeeRate (10.0), so it must be non-null + estimates.forEach { row -> + row.forEach { fee -> + assertNotNull(fee, "Estimate at bucketMax fee rate should not be null") + } + } + } } 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 b108285..99d86bf 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt @@ -45,7 +45,7 @@ class MempoolSnapshotF64ArrayTest { val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) assertEquals(defaultConfig.arraySize, result.buckets.length) - val validIndex = BucketCreator.toArrayIndex(validBucket) + val validIndex = defaultConfig.toArrayIndex(validBucket) assertEquals(600.0, result.buckets[validIndex]) var totalWeight = 0.0 @@ -116,9 +116,9 @@ 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 = defaultConfig.bucketMax + 1 + val validBucket = defaultConfig.bucketMax val snapshot = MempoolSnapshot( @@ -132,12 +132,46 @@ 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) 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) + // Index 0 is the highest bucket — contains both the valid bucket's weight and the folded weight + assertEquals(1500.0, result.buckets[0]) + } + + @Test + fun `fromMempoolSnapshot with custom maxFeeRate folds high-fee buckets`() { + // Use a lower maxFeeRate so some default-range buckets are above it + val config = BucketConfig(maxFeeRate = 500.0) // bucketMax = 621 + val aboveMaxBucket = 700 // above 621, should be folded + val validBucket = 600 // within range + + val snapshot = + MempoolSnapshot( + blockHeight = 100, + timestamp = Instant.now(), + bucketedWeights = mapOf( + aboveMaxBucket to 800L, + validBucket to 400L, + ), + ) + + val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, config) + + assertEquals(config.arraySize, result.buckets.length) + + // Above-max weight folded into index 0 (highest bucket) + assertEquals(800.0, result.buckets[0]) + // Valid bucket at its correct position + assertEquals(400.0, result.buckets[config.toArrayIndex(validBucket)]) + + var totalWeight = 0.0 + for (i in 0 until result.buckets.length) { + totalWeight += result.buckets[i] + } + assertEquals(1200.0, totalWeight) } } From 3683f91deb8d0af54c482aa5561877319f606aea Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Mon, 23 Mar 2026 19:01:00 -0700 Subject: [PATCH 04/15] Wire maxFeeRate through fromMempoolTransactions and validate bucket range fromMempoolTransactions now accepts minFeeRate/maxFeeRate so that snapshot bucketing matches the FeeEstimator config. Previously, custom fee rate bounds only took effect during estimation but not during snapshot creation, silently clamping high-fee transactions to the default bucket max. Also adds a BucketConfig init validation that the discretized bucket range is non-empty, catching edge cases where minFeeRate and maxFeeRate are too close together (e.g. 1.001 and 1.002) which would produce zero-length bucket arrays. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/api/lib.api | 19 +++++++--- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 12 ++++++- .../xyz/block/augur/internal/BucketCreator.kt | 8 +++++ .../xyz/block/augur/FeeEstimatorTest.kt | 36 +++++++++++++++++++ .../block/augur/internal/BucketCreatorTest.kt | 16 +++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/lib/api/lib.api b/lib/api/lib.api index c9ea7e5..b1b9710 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -32,16 +32,20 @@ public final class xyz/block/augur/FeeEstimate { public final class xyz/block/augur/FeeEstimator { public static final field Companion Lxyz/block/augur/FeeEstimator$Companion; + public static final field DEFAULT_MAX_FEE_RATE D + public static final field DEFAULT_MIN_FEE_RATE D public fun ()V public fun (Ljava/util/List;)V 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 { @@ -58,6 +62,10 @@ public final class xyz/block/augur/MempoolSnapshot { public final fun copy (ILjava/time/Instant;Ljava/util/Map;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun copy$default (Lxyz/block/augur/MempoolSnapshot;ILjava/time/Instant;Ljava/util/Map;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; public fun equals (Ljava/lang/Object;)Z + public static final fun fromMempoolTransactions (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; + public static final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; + public static final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;D)Lxyz/block/augur/MempoolSnapshot; + public static final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;DD)Lxyz/block/augur/MempoolSnapshot; public final fun getBlockHeight ()I public final fun getBucketedWeights ()Ljava/util/Map; public final fun getTimestamp ()Ljava/time/Instant; @@ -68,8 +76,11 @@ public final class xyz/block/augur/MempoolSnapshot { public final class xyz/block/augur/MempoolSnapshot$Companion { public final fun empty (ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun empty$default (Lxyz/block/augur/MempoolSnapshot$Companion;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; + public final fun fromMempoolTransactions (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; - public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; + public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;D)Lxyz/block/augur/MempoolSnapshot; + public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;DD)Lxyz/block/augur/MempoolSnapshot; + public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;DDILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } public final class xyz/block/augur/MempoolTransaction { diff --git a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt index ec9ef11..9096851 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -16,6 +16,7 @@ package xyz.block.augur +import xyz.block.augur.internal.BucketConfig import xyz.block.augur.internal.BucketCreator import xyz.block.augur.internal.InternalAugurApi import java.time.Instant @@ -54,15 +55,24 @@ public data class MempoolSnapshot( * @param transactions List of mempool transactions * @param blockHeight Current block height * @param timestamp When the snapshot is taken (defaults to now) + * @param minFeeRate Minimum fee rate in sat/vB for bucketing (default: 1.0). + * Should match the [FeeEstimator]'s minFeeRate. + * @param maxFeeRate Maximum fee rate in sat/vB for bucketing (default: 22027.0). + * Should match the [FeeEstimator]'s maxFeeRate. * @return A new [MempoolSnapshot] instance */ @OptIn(InternalAugurApi::class) + @JvmOverloads + @JvmStatic public fun fromMempoolTransactions( transactions: List, blockHeight: Int, timestamp: Instant = Instant.now(), + minFeeRate: Double = FeeEstimator.DEFAULT_MIN_FEE_RATE, + maxFeeRate: Double = FeeEstimator.DEFAULT_MAX_FEE_RATE, ): MempoolSnapshot { - val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions) + val bucketConfig = BucketConfig(minFeeRate, maxFeeRate) + val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions, bucketConfig) return MempoolSnapshot( blockHeight = blockHeight, 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 eecdc12..aaf1661 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -42,6 +42,14 @@ internal class BucketConfig( val bucketMax: Int = floor(ln(maxFeeRate) * 100).toInt() val arraySize: Int = bucketMax - bucketMin + 1 + init { + require(arraySize >= 1) { + "minFeeRate ($minFeeRate) and maxFeeRate ($maxFeeRate) are too close together: " + + "discretized bucket range is empty (bucketMin=$bucketMin, bucketMax=$bucketMax). " + + "Widen the gap between minFeeRate and maxFeeRate." + } + } + /** * 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. diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index d1c9022..d0d9e84 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -411,6 +411,42 @@ class FeeEstimatorTest { } } + @Test + fun `test fromMempoolTransactions respects custom maxFeeRate`() { + val highFeeRate = 50000.0 + val estimator = FeeEstimator(maxFeeRate = highFeeRate) + + // Create a transaction with a very high fee rate that exceeds the default max + val highFeeTx = MempoolTransaction(weight = 400, fee = 5_000_000) // 50000 sat/vB + + // Using custom maxFeeRate should place this in a bucket above the default max + val snapshotCustom = MempoolSnapshot.fromMempoolTransactions( + transactions = listOf(highFeeTx), + blockHeight = 1, + maxFeeRate = highFeeRate, + ) + + // Using default maxFeeRate should clamp this to the default bucket max + val snapshotDefault = MempoolSnapshot.fromMempoolTransactions( + transactions = listOf(highFeeTx), + blockHeight = 1, + ) + + // The custom snapshot should have a higher bucket index than the default + val maxBucketCustom = snapshotCustom.bucketedWeights.keys.max() + val maxBucketDefault = snapshotDefault.bucketedWeights.keys.max() + assert(maxBucketCustom > maxBucketDefault) { + "Custom maxFeeRate snapshot should have higher bucket index ($maxBucketCustom) than default ($maxBucketDefault)" + } + } + + @Test + fun `test constructor throws if fee rates produce empty bucket range`() { + assertFailsWith { + FeeEstimator(minFeeRate = 1.001, maxFeeRate = 1.002) + } + } + @Test fun `test configure can update minFeeRate and maxFeeRate`() { val estimator = FeeEstimator() 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 e169789..d257973 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -22,6 +22,7 @@ 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) @@ -173,6 +174,21 @@ class BucketCreatorTest { assertTrue(exp(config500.bucketMax.toDouble() / 100) <= 500.0) } + @Test + fun `test BucketConfig throws if discretized bucket range is empty`() { + // minFeeRate = 1.001, maxFeeRate = 1.002: bucketMin = ceil(ln(1.001)*100) = 1, bucketMax = floor(ln(1.002)*100) = 0 + assertFailsWith { + BucketConfig(minFeeRate = 1.001, maxFeeRate = 1.002) + } + } + + @Test + fun `test FeeEstimator throws if fee rates produce empty bucket range`() { + assertFailsWith { + xyz.block.augur.FeeEstimator(minFeeRate = 1.001, maxFeeRate = 1.002) + } + } + @Test fun `test createFeeRateBuckets with very low fee rates`() { // Test 0.1 sat/vB minimum (Bitcoin Core 29.1/30.0+) From d30c115f214217d6ce2b955af23a6cdcdf0e1e17 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 09:17:02 -0700 Subject: [PATCH 05/15] Address PR review feedback for configurable fee rate bounds - Add input validation to BucketConfig.init (positive fee rates, min < max) so fromMempoolTransactions rejects invalid inputs instead of silently producing NaN-derived bucket indices - Fix README default maxFeeRate comment (22026.0 -> 22027.0) - Deduplicate DEFAULT_MIN/MAX_FEE_RATE by delegating FeeEstimator constants to BucketConfig - Add FeeEstimator.createSnapshot() to prevent bucket config drift between snapshot creation and estimation - Move BucketConfig construction after validation in FeeEstimator.init so invalid inputs produce clear error messages - Document asymmetric clamping in calculateBucketIndex (fold high, drop low) - Fix folding test to use separate slots for folded vs valid weight - Remove duplicate empty-bucket-range test from BucketCreatorTest Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 5 +-- lib/api/lib.api | 7 ++-- .../kotlin/xyz/block/augur/FeeEstimator.kt | 36 ++++++++++++++++--- .../xyz/block/augur/internal/BucketCreator.kt | 9 +++++ .../block/augur/internal/BucketCreatorTest.kt | 7 ---- .../internal/MempoolSnapshotF64ArrayTest.kt | 11 +++--- 6 files changed, 56 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 36d636b..026c4ab 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ dependencies { val feeEstimator = FeeEstimator() // Create a mempool snapshot from current transactions -val mempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( +// Using feeEstimator.createSnapshot ensures bucket boundaries match the estimator's config +val mempoolSnapshot = feeEstimator.createSnapshot( transactions = currentMempoolTransactions.map { MempoolTransaction( weight = it.weight.toLong(), @@ -92,7 +93,7 @@ val customFeeEstimator = FeeEstimator( // Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates minFeeRate = 0.1, - // Maximum fee rate in sat/vB (default: 22026.0) + // Maximum fee rate in sat/vB (default: 22027.0) // Estimates above this rate are returned as null maxFeeRate = 1000.0 ) diff --git a/lib/api/lib.api b/lib/api/lib.api index b1b9710..191580c 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -32,8 +32,6 @@ public final class xyz/block/augur/FeeEstimate { public final class xyz/block/augur/FeeEstimator { public static final field Companion Lxyz/block/augur/FeeEstimator$Companion; - public static final field DEFAULT_MAX_FEE_RATE D - public static final field DEFAULT_MIN_FEE_RATE D public fun ()V public fun (Ljava/util/List;)V public fun (Ljava/util/List;Ljava/util/List;)V @@ -46,10 +44,15 @@ public final class xyz/block/augur/FeeEstimator { 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;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 fun createSnapshot (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; + public final fun createSnapshot (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; + public static synthetic fun createSnapshot$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } 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; } diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index baac61c..d91a91d 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -59,8 +59,8 @@ public class FeeEstimator @JvmOverloads public constructor( private val minFeeRate: Double = DEFAULT_MIN_FEE_RATE, private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { - private val bucketConfig = BucketConfig(minFeeRate, maxFeeRate) - private val feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketConfig) + private val bucketConfig: BucketConfig + private val feeEstimatesCalculator: FeeEstimatesCalculator init { require(probabilities.isNotEmpty()) { "At least one probability level must be provided" } @@ -70,6 +70,8 @@ public class FeeEstimator @JvmOverloads public constructor( require(minFeeRate > 0.0) { "minFeeRate must be positive" } require(maxFeeRate > 0.0) { "maxFeeRate must be positive" } require(minFeeRate < maxFeeRate) { "minFeeRate must be less than maxFeeRate" } + bucketConfig = BucketConfig(minFeeRate, maxFeeRate) + feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketConfig) } /** @@ -117,6 +119,30 @@ public class FeeEstimator @JvmOverloads public constructor( return convertToFeeEstimate(feeMatrix, orderedSnapshots.last().timestamp, targets) } + /** + * Creates a [MempoolSnapshot] from raw transactions using this estimator's fee rate bounds. + * + * Prefer this over [MempoolSnapshot.fromMempoolTransactions] to ensure the snapshot's bucket + * boundaries match this estimator's configuration. + * + * @param transactions List of mempool transactions + * @param blockHeight Current block height + * @param timestamp When the snapshot is taken (defaults to now) + * @return A new [MempoolSnapshot] instance + */ + @JvmOverloads + public fun createSnapshot( + transactions: List, + blockHeight: Int, + timestamp: Instant = Instant.now(), + ): MempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( + transactions = transactions, + blockHeight = blockHeight, + timestamp = timestamp, + minFeeRate = minFeeRate, + maxFeeRate = maxFeeRate, + ) + /** * Creates a new [FeeEstimator] with modified settings. * @@ -184,11 +210,13 @@ public class FeeEstimator @JvmOverloads public constructor( /** * Default minimum fee rate in sat/vB. Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes. */ - public const val DEFAULT_MIN_FEE_RATE: Double = 1.0 + @OptIn(InternalAugurApi::class) + public val DEFAULT_MIN_FEE_RATE: Double = BucketConfig.DEFAULT_MIN_FEE_RATE /** * Default maximum fee rate in sat/vB (~exp(10)). Fee estimates above this are returned as null. */ - public const val DEFAULT_MAX_FEE_RATE: Double = 22027.0 + @OptIn(InternalAugurApi::class) + public val DEFAULT_MAX_FEE_RATE: Double = BucketConfig.DEFAULT_MAX_FEE_RATE } } 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 aaf1661..27b1541 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -43,6 +43,9 @@ internal class BucketConfig( val arraySize: Int = bucketMax - bucketMin + 1 init { + require(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } + require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } + require(minFeeRate < maxFeeRate) { "minFeeRate ($minFeeRate) must be less than maxFeeRate ($maxFeeRate)" } require(arraySize >= 1) { "minFeeRate ($minFeeRate) and maxFeeRate ($maxFeeRate) are too close together: " + "discretized bucket range is empty (bucketMin=$bucketMin, bucketMax=$bucketMax). " + @@ -89,6 +92,12 @@ internal object BucketCreator { /** * Calculates bucket index using logarithms, providing more precision in the lower fee levels. + * + * Above-max fee rates are clamped to [BucketConfig.bucketMax] so their block weight is + * preserved in the highest bucket. Below-min fee rates are intentionally NOT clamped here; + * they produce indices below [BucketConfig.bucketMin] and are dropped by + * [MempoolSnapshotF64Array.fromMempoolSnapshot], since sub-relay-minimum transactions + * should not influence fee estimates. */ private fun calculateBucketIndex(feeRate: Double, bucketConfig: BucketConfig): Int = min( (round(ln(feeRate) * 100).toInt()), 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 d257973..53fa233 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -182,13 +182,6 @@ class BucketCreatorTest { } } - @Test - fun `test FeeEstimator throws if fee rates produce empty bucket range`() { - assertFailsWith { - xyz.block.augur.FeeEstimator(minFeeRate = 1.001, maxFeeRate = 1.002) - } - } - @Test fun `test createFeeRateBuckets with very low fee rates`() { // Test 0.1 sat/vB minimum (Bitcoin Core 29.1/30.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 99d86bf..213ec63 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt @@ -118,7 +118,8 @@ class MempoolSnapshotF64ArrayTest { @Test fun `fromMempoolSnapshot folds above-max buckets into highest bucket`() { val oversizedBucket = defaultConfig.bucketMax + 1 - val validBucket = defaultConfig.bucketMax + // Use a bucket below bucketMax so the folded weight and valid weight land in different slots + val validBucket = defaultConfig.bucketMax - 1 val snapshot = MempoolSnapshot( @@ -132,14 +133,16 @@ class MempoolSnapshotF64ArrayTest { val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) - // Above-max weight is folded into the highest bucket (index 0) + // 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[defaultConfig.toArrayIndex(validBucket)]) + // Total weight is preserved var totalWeight = 0.0 for (i in 0 until result.buckets.length) { totalWeight += result.buckets[i] } assertEquals(1500.0, totalWeight) - // Index 0 is the highest bucket — contains both the valid bucket's weight and the folded weight - assertEquals(1500.0, result.buckets[0]) } @Test From 5579a6bcae266135c9bc83cfb5865b934f57a46e Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 09:31:29 -0700 Subject: [PATCH 06/15] Address PR review feedback: fix API compat, validation, and docs - Add @JvmOverloads to configure() to preserve old 4-arg binary signature - Move BucketConfig property computation after require checks (defensive hygiene) - Remove duplicate fee rate validation from FeeEstimator (BucketConfig is source of truth) - Deprecate MempoolSnapshot.fromMempoolTransactions() in favor of FeeEstimator.createSnapshot() - Fix KDoc maxFeeRate default (22026.0 -> 22027.0) - Document new minFeeRate/maxFeeRate params in configure() KDoc - Add inline comment explaining round() vs ceil/floor in calculateBucketIndex - Replace bare assert() with assertNotNull/assertTrue in tests Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/api/lib.api | 6 ++++++ .../main/kotlin/xyz/block/augur/FeeEstimator.kt | 8 ++++---- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 4 ++++ .../xyz/block/augur/internal/BucketCreator.kt | 13 ++++++++++--- .../kotlin/xyz/block/augur/FeeEstimatorTest.kt | 17 ++++++++--------- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/lib/api/lib.api b/lib/api/lib.api index 191580c..8be4e14 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -42,6 +42,12 @@ public final class xyz/block/augur/FeeEstimator { 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 ()Lxyz/block/augur/FeeEstimator; + public final fun configure (Ljava/util/List;)Lxyz/block/augur/FeeEstimator; + public final fun configure (Ljava/util/List;Ljava/util/List;)Lxyz/block/augur/FeeEstimator; + public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;)Lxyz/block/augur/FeeEstimator; + public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;)Lxyz/block/augur/FeeEstimator; + public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;Ljava/lang/Double;)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 fun createSnapshot (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index d91a91d..f46edb4 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -46,7 +46,7 @@ import java.time.Instant * @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 to consider (default: 1.0). Set to 0.1 for * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. - * @property maxFeeRate The maximum fee rate in sat/vB to consider (default: 22026.0). + * @property maxFeeRate The maximum fee rate in sat/vB to consider (default: 22027.0). * Fee estimates above this rate are returned as null; transactions above this rate * are still counted as block weight in the highest bucket. */ @@ -67,9 +67,6 @@ public class FeeEstimator @JvmOverloads public constructor( 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(minFeeRate > 0.0) { "minFeeRate must be positive" } - require(maxFeeRate > 0.0) { "maxFeeRate must be positive" } - require(minFeeRate < maxFeeRate) { "minFeeRate must be less than maxFeeRate" } bucketConfig = BucketConfig(minFeeRate, maxFeeRate) feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketConfig) } @@ -150,8 +147,11 @@ 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 */ + @JvmOverloads public fun configure( probabilities: List? = null, blockTargets: List? = null, diff --git a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt index 9096851..aa7dd0d 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -61,6 +61,10 @@ public data class MempoolSnapshot( * Should match the [FeeEstimator]'s maxFeeRate. * @return A new [MempoolSnapshot] instance */ + @Deprecated( + message = "Use FeeEstimator.createSnapshot() to ensure bucket boundaries match the estimator's config.", + replaceWith = ReplaceWith("FeeEstimator().createSnapshot(transactions, blockHeight, timestamp)"), + ) @OptIn(InternalAugurApi::class) @JvmOverloads @JvmStatic 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 27b1541..d820b9b 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -38,14 +38,17 @@ internal class BucketConfig( minFeeRate: Double = DEFAULT_MIN_FEE_RATE, maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { - val bucketMin: Int = ceil(ln(minFeeRate) * 100).toInt() - val bucketMax: Int = floor(ln(maxFeeRate) * 100).toInt() - val arraySize: Int = bucketMax - bucketMin + 1 + val bucketMin: Int + val bucketMax: Int + val arraySize: Int init { require(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } require(minFeeRate < maxFeeRate) { "minFeeRate ($minFeeRate) must be less than maxFeeRate ($maxFeeRate)" } + bucketMin = ceil(ln(minFeeRate) * 100).toInt() + bucketMax = floor(ln(maxFeeRate) * 100).toInt() + arraySize = bucketMax - bucketMin + 1 require(arraySize >= 1) { "minFeeRate ($minFeeRate) and maxFeeRate ($maxFeeRate) are too close together: " + "discretized bucket range is empty (bucketMin=$bucketMin, bucketMax=$bucketMax). " + @@ -100,6 +103,10 @@ internal object BucketCreator { * should not influence fee estimates. */ private fun calculateBucketIndex(feeRate: Double, bucketConfig: BucketConfig): Int = min( + // round() is correct here: each transaction maps to its nearest bucket. + // BucketConfig uses ceil/floor for *boundaries* to guarantee the range stays within the + // user's configured min/max fee rates, but individual transactions should snap to the + // closest discrete bucket rather than being biased up or down. (round(ln(feeRate) * 100).toInt()), bucketConfig.bucketMax, ) diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index d0d9e84..89b0a06 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") } } } @@ -365,9 +365,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") } } } From 7046a249f30dfccf56932213dcb642afd11157bd Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 09:38:51 -0700 Subject: [PATCH 07/15] Remove @JvmStatic from deprecated method, clarify maxFeeRate docs, use assertTrue - Drop @JvmStatic from deprecated fromMempoolTransactions (no need to expand Java API surface for a deprecated method) - Drop ReplaceWith to avoid suggesting throwaway FeeEstimator instances - Clarify in README that above-max transactions are folded into highest bucket - Replace bare assert() with assertTrue() in new tests for consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 3 ++- lib/api/lib.api | 4 ---- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 2 -- .../kotlin/xyz/block/augur/FeeEstimatorTest.kt | 16 ++++------------ 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 026c4ab..be8130a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,8 @@ val customFeeEstimator = FeeEstimator( minFeeRate = 0.1, // Maximum fee rate in sat/vB (default: 22027.0) - // Estimates above this rate are returned as null + // Estimates above this rate are returned as null; transactions above this rate + // are still counted as block weight in the highest bucket maxFeeRate = 1000.0 ) ``` diff --git a/lib/api/lib.api b/lib/api/lib.api index 8be4e14..089c8c6 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -71,10 +71,6 @@ public final class xyz/block/augur/MempoolSnapshot { public final fun copy (ILjava/time/Instant;Ljava/util/Map;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun copy$default (Lxyz/block/augur/MempoolSnapshot;ILjava/time/Instant;Ljava/util/Map;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; public fun equals (Ljava/lang/Object;)Z - public static final fun fromMempoolTransactions (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; - public static final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; - public static final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;D)Lxyz/block/augur/MempoolSnapshot; - public static final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;DD)Lxyz/block/augur/MempoolSnapshot; public final fun getBlockHeight ()I public final fun getBucketedWeights ()Ljava/util/Map; public final fun getTimestamp ()Ljava/time/Instant; diff --git a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt index aa7dd0d..3fc989c 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -63,11 +63,9 @@ public data class MempoolSnapshot( */ @Deprecated( message = "Use FeeEstimator.createSnapshot() to ensure bucket boundaries match the estimator's config.", - replaceWith = ReplaceWith("FeeEstimator().createSnapshot(transactions, blockHeight, timestamp)"), ) @OptIn(InternalAugurApi::class) @JvmOverloads - @JvmStatic public fun fromMempoolTransactions( transactions: List, blockHeight: Int, diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 89b0a06..7135745 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -383,9 +383,7 @@ class FeeEstimatorTest { FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> val feeRate = estimate.getFeeRate(target.toInt(), probability) if (feeRate != null) { - assert(feeRate <= 50.0) { - "Fee rate $feeRate should be <= 50.0 for target=$target, probability=$probability" - } + assertTrue(feeRate <= 50.0, "Fee rate $feeRate should be <= 50.0 for target=$target, probability=$probability") } } } @@ -403,9 +401,7 @@ class FeeEstimatorTest { FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> val feeRate = estimate.getFeeRate(target.toInt(), 0.5) if (feeRate != null) { - assert(feeRate <= 500.0) { - "Fee rate $feeRate should be <= 500.0 after configure" - } + assertTrue(feeRate <= 500.0, "Fee rate $feeRate should be <= 500.0 after configure") } } } @@ -434,9 +430,7 @@ class FeeEstimatorTest { // The custom snapshot should have a higher bucket index than the default val maxBucketCustom = snapshotCustom.bucketedWeights.keys.max() val maxBucketDefault = snapshotDefault.bucketedWeights.keys.max() - assert(maxBucketCustom > maxBucketDefault) { - "Custom maxFeeRate snapshot should have higher bucket index ($maxBucketCustom) than default ($maxBucketDefault)" - } + assertTrue(maxBucketCustom > maxBucketDefault, "Custom maxFeeRate snapshot should have higher bucket index ($maxBucketCustom) than default ($maxBucketDefault)") } @Test @@ -457,9 +451,7 @@ class FeeEstimatorTest { FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> val feeRate = estimate.getFeeRate(target.toInt(), 0.5) if (feeRate != null) { - assert(feeRate <= 100.0) { - "Fee rate $feeRate should be <= 100.0 after configure with maxFeeRate=100" - } + assertTrue(feeRate <= 100.0, "Fee rate $feeRate should be <= 100.0 after configure with maxFeeRate=100") } } } From bc3e773ae33c00f3673648b4b626908d8210a820 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 09:50:09 -0700 Subject: [PATCH 08/15] Remove deprecation and unnecessary @JvmOverloads from public API Keep fromMempoolTransactions supported (no external Java callers exist), remove @JvmOverloads from configure/createSnapshot/fromMempoolTransactions to avoid unnecessary API surface, and document DEFAULT_MAX_FEE_RATE choice. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/api/lib.api | 10 ---------- lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt | 5 ++--- lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt | 10 ++++------ 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/lib/api/lib.api b/lib/api/lib.api index 089c8c6..e56c71f 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -42,15 +42,8 @@ public final class xyz/block/augur/FeeEstimator { 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 ()Lxyz/block/augur/FeeEstimator; - public final fun configure (Ljava/util/List;)Lxyz/block/augur/FeeEstimator; - public final fun configure (Ljava/util/List;Ljava/util/List;)Lxyz/block/augur/FeeEstimator; - public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;)Lxyz/block/augur/FeeEstimator; - public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;)Lxyz/block/augur/FeeEstimator; - public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;Ljava/lang/Double;)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 fun createSnapshot (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; public final fun createSnapshot (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun createSnapshot$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } @@ -81,9 +74,6 @@ public final class xyz/block/augur/MempoolSnapshot { public final class xyz/block/augur/MempoolSnapshot$Companion { public final fun empty (ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun empty$default (Lxyz/block/augur/MempoolSnapshot$Companion;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; - public final fun fromMempoolTransactions (Ljava/util/List;I)Lxyz/block/augur/MempoolSnapshot; - public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; - public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;D)Lxyz/block/augur/MempoolSnapshot; public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;DD)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;DDILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index f46edb4..0b12df6 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -127,7 +127,6 @@ public class FeeEstimator @JvmOverloads public constructor( * @param timestamp When the snapshot is taken (defaults to now) * @return A new [MempoolSnapshot] instance */ - @JvmOverloads public fun createSnapshot( transactions: List, blockHeight: Int, @@ -151,7 +150,6 @@ public class FeeEstimator @JvmOverloads public constructor( * @param maxFeeRate New maximum fee rate in sat/vB (null to keep current) * @return A new [FeeEstimator] instance with the specified settings */ - @JvmOverloads public fun configure( probabilities: List? = null, blockTargets: List? = null, @@ -214,7 +212,8 @@ public class FeeEstimator @JvmOverloads public constructor( public val DEFAULT_MIN_FEE_RATE: Double = BucketConfig.DEFAULT_MIN_FEE_RATE /** - * Default maximum fee rate in sat/vB (~exp(10)). Fee estimates above this are returned as null. + * Default maximum fee rate in sat/vB. Chosen so that `floor(ln(22027) * 100) == 1000`, + * preserving the legacy bucket count from before fee rate bounds were configurable. */ @OptIn(InternalAugurApi::class) public val DEFAULT_MAX_FEE_RATE: Double = BucketConfig.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 3fc989c..85172a0 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -56,16 +56,14 @@ public data class MempoolSnapshot( * @param blockHeight Current block height * @param timestamp When the snapshot is taken (defaults to now) * @param minFeeRate Minimum fee rate in sat/vB for bucketing (default: 1.0). - * Should match the [FeeEstimator]'s minFeeRate. + * If using custom fee rate bounds, prefer [FeeEstimator.createSnapshot] to ensure + * the snapshot's bucket boundaries match the estimator's configuration. * @param maxFeeRate Maximum fee rate in sat/vB for bucketing (default: 22027.0). - * Should match the [FeeEstimator]'s maxFeeRate. + * If using custom fee rate bounds, prefer [FeeEstimator.createSnapshot] to ensure + * the snapshot's bucket boundaries match the estimator's configuration. * @return A new [MempoolSnapshot] instance */ - @Deprecated( - message = "Use FeeEstimator.createSnapshot() to ensure bucket boundaries match the estimator's config.", - ) @OptIn(InternalAugurApi::class) - @JvmOverloads public fun fromMempoolTransactions( transactions: List, blockHeight: Int, From 62dd3f1fb84663e14021d2937c42d3750164235f Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 09:59:49 -0700 Subject: [PATCH 09/15] Clean up PR review nits: remove unused test var, simplify README comment, clarify minFeeRate KDoc Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 1 - lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt | 3 ++- lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index be8130a..32f4bb2 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,6 @@ dependencies { val feeEstimator = FeeEstimator() // Create a mempool snapshot from current transactions -// Using feeEstimator.createSnapshot ensures bucket boundaries match the estimator's config val mempoolSnapshot = feeEstimator.createSnapshot( transactions = currentMempoolTransactions.map { MempoolTransaction( diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 0b12df6..3bcbf48 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -45,7 +45,8 @@ 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 to consider (default: 1.0). Set to 0.1 for - * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. + * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Transactions whose fee rate + * rounds to a bucket below this threshold are excluded. * @property maxFeeRate The maximum fee rate in sat/vB to consider (default: 22027.0). * Fee estimates above this rate are returned as null; transactions above this rate * are still counted as block weight in the highest bucket. diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 7135745..58f261a 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -409,7 +409,6 @@ class FeeEstimatorTest { @Test fun `test fromMempoolTransactions respects custom maxFeeRate`() { val highFeeRate = 50000.0 - val estimator = FeeEstimator(maxFeeRate = highFeeRate) // Create a transaction with a very high fee rate that exceeds the default max val highFeeTx = MempoolTransaction(weight = 400, fee = 5_000_000) // 50000 sat/vB From abb008f9651d464b956e37b266e0b01691e2f895 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 10:09:46 -0700 Subject: [PATCH 10/15] Fix maxFeeRate doc precision and use assertNotNull/assertTrue consistently - Clarify that estimates whose fee rate *exceeds* the bound are null (not "above"), matching the <= boundary check in prepareResultArray - Replace bare assert() with assertNotNull/assertTrue in FeeEstimatesCalculatorTest for consistency with other test cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 ++-- lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt | 4 ++-- .../xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 32f4bb2..2a76cfb 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ val customFeeEstimator = FeeEstimator( minFeeRate = 0.1, // Maximum fee rate in sat/vB (default: 22027.0) - // Estimates above this rate are returned as null; transactions above this rate - // are still counted as block weight in the highest bucket + // Estimates whose fee rate exceeds this bound are returned as null; + // transactions above this rate are still counted as block weight in the highest bucket maxFeeRate = 1000.0 ) ``` diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 3bcbf48..69a6b66 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -48,8 +48,8 @@ import java.time.Instant * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Transactions whose fee rate * rounds to a bucket below this threshold are excluded. * @property maxFeeRate The maximum fee rate in sat/vB to consider (default: 22027.0). - * Fee estimates above this rate are returned as null; transactions above this rate - * are still counted as block weight in the highest bucket. + * Fee estimates whose fee rate exceeds this bound are returned as null; transactions + * above this rate are still counted as block weight in the highest bucket. */ @OptIn(InternalAugurApi::class) public class FeeEstimator @JvmOverloads public constructor( 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 172c6b5..e20ba6a 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt @@ -95,7 +95,8 @@ class FeeEstimatesCalculatorTest { ) // With these parameters, we expect some buckets to be fully mined - assert(result != null && result < defaultConfig.bucketMax) + assertNotNull(result) + assertTrue(result < defaultConfig.bucketMax) } @Test From 962e15d52d356a644653d30d135478ca7a117ea4 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 11:07:07 -0700 Subject: [PATCH 11/15] Decouple maxFeeRate from simulation array layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxFeeRate was previously used to derive bucketMax, which sized the internal simulation arrays. This coupled a user-facing output filter to the internal state space, changing simulation dynamics (bucket folding, inflow distortion) when callers lowered maxFeeRate. Now: - Rename BucketConfig → BucketLayout (internal simulation layout only) - BucketLayout takes only minFeeRate; bucketMax is fixed at 1000 - maxFeeRate is passed separately to FeeEstimatesCalculator as a pure output filter in prepareResultArray - Remove maxFeeRate from MempoolSnapshot.fromMempoolTransactions - Remove minFeeRate < maxFeeRate validation (independent concerns) Default behavior is unchanged: bucketMax=1000, maxFeeRate=22027.0. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- lib/api/lib.api | 4 +- .../kotlin/xyz/block/augur/FeeEstimator.kt | 32 +++---- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 10 +-- .../xyz/block/augur/internal/BucketCreator.kt | 53 +++++------ .../augur/internal/FeeEstimatesCalculator.kt | 18 ++-- .../block/augur/internal/InflowCalculator.kt | 6 +- .../augur/internal/MempoolSnapshotF64Array.kt | 12 +-- .../xyz/block/augur/FeeEstimatorTest.kt | 47 ++++------ .../block/augur/internal/BucketCreatorTest.kt | 49 +++++------ .../internal/FeeEstimatesCalculatorTest.kt | 88 +++++++++++++------ .../augur/internal/InflowCalculatorTest.kt | 46 +++++----- .../internal/MempoolSnapshotF64ArrayTest.kt | 65 ++++---------- 13 files changed, 206 insertions(+), 230 deletions(-) diff --git a/README.md b/README.md index 2a76cfb..c83a666 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,9 @@ val customFeeEstimator = FeeEstimator( // Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates minFeeRate = 0.1, - // Maximum fee rate in sat/vB (default: 22027.0) - // Estimates whose fee rate exceeds this bound are returned as null; - // transactions above this rate are still counted as block weight in the highest bucket + // 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/lib/api/lib.api b/lib/api/lib.api index e56c71f..9b020eb 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -74,8 +74,8 @@ public final class xyz/block/augur/MempoolSnapshot { public final class xyz/block/augur/MempoolSnapshot$Companion { public final fun empty (ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun empty$default (Lxyz/block/augur/MempoolSnapshot$Companion;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; - public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;DD)Lxyz/block/augur/MempoolSnapshot; - public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;DDILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; + public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;D)Lxyz/block/augur/MempoolSnapshot; + public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;DILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } public final class xyz/block/augur/MempoolTransaction { diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 69a6b66..b08341f 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -16,7 +16,7 @@ package xyz.block.augur -import xyz.block.augur.internal.BucketConfig +import xyz.block.augur.internal.BucketLayout import xyz.block.augur.internal.FeeEstimatesCalculator import xyz.block.augur.internal.InflowCalculator import xyz.block.augur.internal.InternalAugurApi @@ -47,9 +47,9 @@ import java.time.Instant * @property minFeeRate The minimum fee rate in sat/vB to consider (default: 1.0). Set to 0.1 for * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Transactions whose fee rate * rounds to a bucket below this threshold are excluded. - * @property maxFeeRate The maximum fee rate in sat/vB to consider (default: 22027.0). - * Fee estimates whose fee rate exceeds this bound are returned as null; transactions - * above this rate are still counted as block weight in the highest bucket. + * @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( @@ -60,7 +60,7 @@ public class FeeEstimator @JvmOverloads public constructor( private val minFeeRate: Double = DEFAULT_MIN_FEE_RATE, private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { - private val bucketConfig: BucketConfig + private val bucketLayout: BucketLayout private val feeEstimatesCalculator: FeeEstimatesCalculator init { @@ -68,8 +68,9 @@ public class FeeEstimator @JvmOverloads public constructor( 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" } - bucketConfig = BucketConfig(minFeeRate, maxFeeRate) - feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketConfig) + require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } + bucketLayout = BucketLayout(minFeeRate) + feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate) } /** @@ -95,15 +96,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, bucketConfig) } + 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, bucketConfig) - val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration, bucketConfig) + val shortTermInflows = InflowCalculator.calculateInflows(simdSnapshots, shortTermWindowDuration, bucketLayout) + val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration, bucketLayout) val (calculator, targets) = if (numOfBlocks != null) { - FeeEstimatesCalculator(probabilities, listOf(numOfBlocks), bucketConfig) to listOf(numOfBlocks) + FeeEstimatesCalculator(probabilities, listOf(numOfBlocks), bucketLayout, maxFeeRate) to listOf(numOfBlocks) } else { feeEstimatesCalculator to blockTargets } @@ -137,7 +138,6 @@ public class FeeEstimator @JvmOverloads public constructor( blockHeight = blockHeight, timestamp = timestamp, minFeeRate = minFeeRate, - maxFeeRate = maxFeeRate, ) /** @@ -210,13 +210,13 @@ public class FeeEstimator @JvmOverloads public constructor( * Default minimum fee rate in sat/vB. Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes. */ @OptIn(InternalAugurApi::class) - public val DEFAULT_MIN_FEE_RATE: Double = BucketConfig.DEFAULT_MIN_FEE_RATE + public val DEFAULT_MIN_FEE_RATE: Double = BucketLayout.DEFAULT_MIN_FEE_RATE /** - * Default maximum fee rate in sat/vB. Chosen so that `floor(ln(22027) * 100) == 1000`, - * preserving the legacy bucket count from before fee rate bounds were configurable. + * Default maximum fee rate in sat/vB for reporting. Estimates above this value are + * returned as null. Chosen as ~exp(10) to preserve the legacy simulation bucket count. */ @OptIn(InternalAugurApi::class) - public val DEFAULT_MAX_FEE_RATE: Double = BucketConfig.DEFAULT_MAX_FEE_RATE + 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 85172a0..fbfcedf 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -16,8 +16,8 @@ package xyz.block.augur -import xyz.block.augur.internal.BucketConfig import xyz.block.augur.internal.BucketCreator +import xyz.block.augur.internal.BucketLayout import xyz.block.augur.internal.InternalAugurApi import java.time.Instant @@ -58,9 +58,6 @@ public data class MempoolSnapshot( * @param minFeeRate Minimum fee rate in sat/vB for bucketing (default: 1.0). * If using custom fee rate bounds, prefer [FeeEstimator.createSnapshot] to ensure * the snapshot's bucket boundaries match the estimator's configuration. - * @param maxFeeRate Maximum fee rate in sat/vB for bucketing (default: 22027.0). - * If using custom fee rate bounds, prefer [FeeEstimator.createSnapshot] to ensure - * the snapshot's bucket boundaries match the estimator's configuration. * @return A new [MempoolSnapshot] instance */ @OptIn(InternalAugurApi::class) @@ -69,10 +66,9 @@ public data class MempoolSnapshot( blockHeight: Int, timestamp: Instant = Instant.now(), minFeeRate: Double = FeeEstimator.DEFAULT_MIN_FEE_RATE, - maxFeeRate: Double = FeeEstimator.DEFAULT_MAX_FEE_RATE, ): MempoolSnapshot { - val bucketConfig = BucketConfig(minFeeRate, maxFeeRate) - val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions, bucketConfig) + val bucketLayout = BucketLayout(minFeeRate) + val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions, bucketLayout) return MempoolSnapshot( blockHeight = blockHeight, 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 d820b9b..b73ecfa 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -18,42 +18,38 @@ package xyz.block.augur.internal import xyz.block.augur.MempoolTransaction import kotlin.math.ceil -import kotlin.math.floor import kotlin.math.ln import kotlin.math.min import kotlin.math.round /** - * Holds bucket boundaries derived from minimum and maximum fee rates. + * 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]. - * Uses floor for [bucketMax] so the highest bucket never represents a fee rate above [maxFeeRate]. * * @property bucketMin Minimum bucket index, computed as ceil(ln(minFeeRate) * 100) - * @property bucketMax Maximum bucket index, computed as floor(ln(maxFeeRate) * 100) + * @property bucketMax Fixed simulation upper bound (1000) * @property arraySize Total number of bucket array slots (bucketMax - bucketMin + 1) */ @InternalAugurApi -internal class BucketConfig( +internal class BucketLayout( minFeeRate: Double = DEFAULT_MIN_FEE_RATE, - maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { val bucketMin: Int - val bucketMax: Int + val bucketMax: Int = SIMULATION_BUCKET_MAX val arraySize: Int init { require(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } - require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } - require(minFeeRate < maxFeeRate) { "minFeeRate ($minFeeRate) must be less than maxFeeRate ($maxFeeRate)" } bucketMin = ceil(ln(minFeeRate) * 100).toInt() - bucketMax = floor(ln(maxFeeRate) * 100).toInt() - arraySize = bucketMax - bucketMin + 1 - require(arraySize >= 1) { - "minFeeRate ($minFeeRate) and maxFeeRate ($maxFeeRate) are too close together: " + - "discretized bucket range is empty (bucketMin=$bucketMin, bucketMax=$bucketMax). " + - "Widen the gap between minFeeRate and maxFeeRate." + require(bucketMin <= bucketMax) { + "minFeeRate ($minFeeRate) is too high: bucketMin ($bucketMin) exceeds simulation ceiling ($bucketMax)" } + arraySize = bucketMax - bucketMin + 1 } /** @@ -69,9 +65,14 @@ internal class BucketConfig( companion object { internal const val DEFAULT_MIN_FEE_RATE = 1.0 - internal const val DEFAULT_MAX_FEE_RATE = 22027.0 // > exp(10) ≈ 22026.47, so floor gives bucket 1000 - val DEFAULT = BucketConfig() + /** + * 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() } } @@ -86,28 +87,28 @@ internal object BucketCreator { */ fun createFeeRateBuckets( feeRateWeightPairs: List, - bucketConfig: BucketConfig = BucketConfig.DEFAULT, + bucketLayout: BucketLayout = BucketLayout.DEFAULT, ): Map = feeRateWeightPairs - .groupingBy { calculateBucketIndex(it.getFeeRate(), bucketConfig) } + .groupingBy { calculateBucketIndex(it.getFeeRate(), bucketLayout) } .fold(0L) { acc, tx -> acc + tx.weight } .toSortedMap() /** * Calculates bucket index using logarithms, providing more precision in the lower fee levels. * - * Above-max fee rates are clamped to [BucketConfig.bucketMax] so their block weight is - * preserved in the highest bucket. Below-min fee rates are intentionally NOT clamped here; - * they produce indices below [BucketConfig.bucketMin] and are dropped by + * Above-max fee rates are clamped to [BucketLayout.bucketMax] (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, bucketConfig: BucketConfig): Int = min( + private fun calculateBucketIndex(feeRate: Double, bucketLayout: BucketLayout): Int = min( // round() is correct here: each transaction maps to its nearest bucket. - // BucketConfig uses ceil/floor for *boundaries* to guarantee the range stays within the - // user's configured min/max fee rates, but individual transactions should snap to the + // 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()), - bucketConfig.bucketMax, + bucketLayout.bucketMax, ) } 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 c72e1a4..1e58290 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -19,7 +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 kotlin.math.exp import kotlin.math.min import kotlin.math.pow @@ -33,7 +32,8 @@ import kotlin.math.pow internal class FeeEstimatesCalculator( private val probabilities: List, private val blockTargets: List, - private val bucketConfig: BucketConfig = BucketConfig.DEFAULT, + private val bucketLayout: BucketLayout = BucketLayout.DEFAULT, + private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE, ) { private val expectedBlocksMined by lazy { getExpectedBlocksMined() } @@ -44,7 +44,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, @@ -173,9 +173,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 -> bucketConfig.bucketMin // all weights are zero so we can use the cheapest fee rate - -1 -> bucketConfig.bucketMax + 1 // return null - else -> bucketConfig.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) } } @@ -208,12 +208,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 configured bucketMax - val maxAllowedFeeRate = exp(bucketConfig.bucketMax.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 } } } } @@ -262,5 +259,6 @@ internal class FeeEstimatesCalculator( companion object { const val BLOCK_SIZE_WEIGHT_UNITS = 4_000_000 + 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 cb780e3..4bbe1a1 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/InflowCalculator.kt @@ -37,9 +37,9 @@ internal object InflowCalculator { fun calculateInflows( mempoolSnapshots: List, timeframe: Duration, - bucketConfig: BucketConfig = BucketConfig.DEFAULT, + bucketLayout: BucketLayout = BucketLayout.DEFAULT, ): F64Array { - if (mempoolSnapshots.isEmpty()) return F64Array(bucketConfig.arraySize) + if (mempoolSnapshots.isEmpty()) return F64Array(bucketLayout.arraySize) // First sort the snapshots by timestamp val orderedSnapshots = mempoolSnapshots.sortedBy { it.timestamp } @@ -48,7 +48,7 @@ internal object InflowCalculator { val startTime = endTime - timeframe val relevantSnapshots = orderedSnapshots.filter { it.timestamp in startTime..endTime } - val inflows = F64Array(bucketConfig.arraySize) + 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/MempoolSnapshotF64Array.kt b/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt index 2248741..0b339c9 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/MempoolSnapshotF64Array.kt @@ -35,18 +35,18 @@ internal data class MempoolSnapshotF64Array( */ fun fromMempoolSnapshot( snapshot: MempoolSnapshot, - bucketConfig: BucketConfig = BucketConfig.DEFAULT, + bucketLayout: BucketLayout = BucketLayout.DEFAULT, ): MempoolSnapshotF64Array { - val feeRateBuckets = F64Array(bucketConfig.arraySize) + val feeRateBuckets = F64Array(bucketLayout.arraySize) snapshot.bucketedWeights.forEach { (bucket, weight) -> when { - bucket > bucketConfig.bucketMax -> { - // Fold above-max into the highest bucket so their block weight is still counted + bucket > bucketLayout.bucketMax -> { + // Fold above simulation ceiling into the highest bucket so their block weight is still counted feeRateBuckets[0] += weight.toDouble() } - bucket >= bucketConfig.bucketMin -> { + bucket >= bucketLayout.bucketMin -> { // Inserting into reverse order will allow us to mine the highest fee rate buckets first - feeRateBuckets[bucketConfig.toArrayIndex(bucket)] += weight.toDouble() + 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 58f261a..73163d9 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -340,9 +340,20 @@ class FeeEstimatorTest { } @Test - fun `test constructor throws if minFeeRate exceeds maxFeeRate`() { - assertFailsWith { - FeeEstimator(minFeeRate = 100.0, maxFeeRate = 50.0) + 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 -> + val feeRate = estimate.getFeeRate(target.toInt(), probability) + // Estimates are either null or very low — this is a valid (if unusual) configuration + } } } @@ -407,36 +418,16 @@ class FeeEstimatorTest { } @Test - fun `test fromMempoolTransactions respects custom maxFeeRate`() { - val highFeeRate = 50000.0 - - // Create a transaction with a very high fee rate that exceeds the default max + 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 - // Using custom maxFeeRate should place this in a bucket above the default max - val snapshotCustom = MempoolSnapshot.fromMempoolTransactions( + // With default maxFeeRate, the snapshot should clamp to the simulation ceiling (bucket 1000) + val snapshot = MempoolSnapshot.fromMempoolTransactions( transactions = listOf(highFeeTx), blockHeight = 1, - maxFeeRate = highFeeRate, ) - - // Using default maxFeeRate should clamp this to the default bucket max - val snapshotDefault = MempoolSnapshot.fromMempoolTransactions( - transactions = listOf(highFeeTx), - blockHeight = 1, - ) - - // The custom snapshot should have a higher bucket index than the default - val maxBucketCustom = snapshotCustom.bucketedWeights.keys.max() - val maxBucketDefault = snapshotDefault.bucketedWeights.keys.max() - assertTrue(maxBucketCustom > maxBucketDefault, "Custom maxFeeRate snapshot should have higher bucket index ($maxBucketCustom) than default ($maxBucketDefault)") - } - - @Test - fun `test constructor throws if fee rates produce empty bucket range`() { - assertFailsWith { - FeeEstimator(minFeeRate = 1.001, maxFeeRate = 1.002) - } + val maxBucket = snapshot.bucketedWeights.keys.max() + assertEquals(1000, maxBucket, "High fee rate transactions should be clamped to simulation ceiling") } @Test 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 53fa233..7e8f3f3 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -136,49 +136,40 @@ class BucketCreatorTest { val buckets = BucketCreator.createFeeRateBuckets(transactions) - // The bucket index should be at BUCKET_MAX - assertTrue(buckets.containsKey(BucketConfig.DEFAULT.bucketMax)) - assertEquals(400L, buckets[BucketConfig.DEFAULT.bucketMax]) + // The bucket index should be at the simulation ceiling + assertTrue(buckets.containsKey(BucketLayout.DEFAULT.bucketMax)) + assertEquals(400L, buckets[BucketLayout.DEFAULT.bucketMax]) } @Test - fun `test BucketConfig bucketMin uses ceil so lowest bucket never undershoots minFeeRate`() { - val config01 = BucketConfig(0.1) - assertEquals(-230, config01.bucketMin) + fun `test BucketLayout bucketMin uses ceil so lowest bucket never undershoots minFeeRate`() { + val layout01 = BucketLayout(0.1) + assertEquals(-230, layout01.bucketMin) - val config10 = BucketConfig(1.0) - assertEquals(0, config10.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 config015 = BucketConfig(0.15) - assertEquals(-189, config015.bucketMin) - assertTrue(exp(config015.bucketMin.toDouble() / 100) >= 0.15) + val layout015 = BucketLayout(0.15) + assertEquals(-189, layout015.bucketMin) + assertTrue(exp(layout015.bucketMin.toDouble() / 100) >= 0.15) } @Test - fun `test BucketConfig bucketMax uses floor so highest bucket never overshoots maxFeeRate`() { - val configDefault = BucketConfig.DEFAULT - assertEquals(1000, configDefault.bucketMax) - - // 1000 sat/vB: ln(1000) * 100 = 690.77..., floor = 690 - // exp(690/100) = exp(6.90) ≈ 992.27, which is <= 1000 - val config1000 = BucketConfig(maxFeeRate = 1000.0) - assertEquals(690, config1000.bucketMax) - assertTrue(exp(config1000.bucketMax.toDouble() / 100) <= 1000.0) - - // 500 sat/vB: ln(500) * 100 = 621.46..., floor = 621 - // exp(621/100) = exp(6.21) ≈ 496.58, which is <= 500 - val config500 = BucketConfig(maxFeeRate = 500.0) - assertEquals(621, config500.bucketMax) - assertTrue(exp(config500.bucketMax.toDouble() / 100) <= 500.0) + 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 BucketConfig throws if discretized bucket range is empty`() { - // minFeeRate = 1.001, maxFeeRate = 1.002: bucketMin = ceil(ln(1.001)*100) = 1, bucketMax = floor(ln(1.002)*100) = 0 + fun `test BucketLayout throws if minFeeRate too high for simulation ceiling`() { + // A minFeeRate that produces bucketMin > 1000 should fail assertFailsWith { - BucketConfig(minFeeRate = 1.001, maxFeeRate = 1.002) + BucketLayout(minFeeRate = 30000.0) // ln(30000)*100 ≈ 1031 > 1000 } } 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 e20ba6a..e6ac5ab 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt @@ -32,7 +32,7 @@ import kotlin.test.assertTrue class FeeEstimatesCalculatorTest { private val blockTargets = listOf(3.0, 12.0, 144.0) private val probabilities = listOf(0.5, 0.95) - private val defaultConfig = BucketConfig.DEFAULT + private val defaultLayout = BucketLayout.DEFAULT private val calculator = FeeEstimatesCalculator( @@ -58,13 +58,13 @@ class FeeEstimatesCalculatorTest { @Test fun `test findBestIndex when all weights are mined`() { val weights = F64Array(5) { 0.0 } - assertEquals(defaultConfig.bucketMin, 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(defaultConfig.bucketMax + 1, calculator.findBestIndex(weights)) + assertEquals(defaultLayout.bucketMax + 1, calculator.findBestIndex(weights)) } @Test @@ -76,8 +76,8 @@ class FeeEstimatesCalculatorTest { weights[3] = 1000.0 // unmined weights[4] = 1000.0 // unmined - // Should return defaultConfig.bucketMax - 1 since index 1 is the last fully mined bucket - assertEquals(defaultConfig.bucketMax - 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 @@ -96,7 +96,7 @@ class FeeEstimatesCalculatorTest { // With these parameters, we expect some buckets to be fully mined assertNotNull(result) - assertTrue(result < defaultConfig.bucketMax) + assertTrue(result < defaultLayout.bucketMax) } @Test @@ -155,7 +155,7 @@ class FeeEstimatesCalculatorTest { weights[3] = 1000.0 weights[4] = 1000.0 - assertEquals(defaultConfig.bucketMax, calculator.findBestIndex(weights)) + assertEquals(defaultLayout.bucketMax, calculator.findBestIndex(weights)) } @Test @@ -173,7 +173,7 @@ class FeeEstimatesCalculatorTest { ) // With such a large block size, all buckets should be mined - assertEquals(defaultConfig.bucketMin, result) + assertEquals(defaultLayout.bucketMin, result) } @Test @@ -194,7 +194,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(defaultConfig.bucketMax - 1, result) + assertEquals(defaultLayout.bucketMax - 1, result) } @Test @@ -211,17 +211,17 @@ class FeeEstimatesCalculatorTest { blockSize = 100.0, ) - assertEquals(defaultConfig.bucketMin, result) + assertEquals(defaultLayout.bucketMin, result) } @Test fun `test near-minimum fee bucket never emits sub 0_1 sat per vB`() { - val lowFeeConfig = BucketConfig(0.1) - val lowFeeCalculator = FeeEstimatesCalculator(probabilities, blockTargets, lowFeeConfig) + val lowFeeLayout = BucketLayout(0.1) + val lowFeeCalculator = FeeEstimatesCalculator(probabilities, blockTargets, lowFeeLayout) val nearMinimumFeeRate = 0.0998 val bucketIndex = (ln(nearMinimumFeeRate) * 100).roundToInt() - assertEquals(lowFeeConfig.bucketMin, bucketIndex) + assertEquals(lowFeeLayout.bucketMin, bucketIndex) val snapshot = MempoolSnapshot( @@ -230,8 +230,8 @@ class FeeEstimatesCalculatorTest { bucketedWeights = mapOf(bucketIndex to 4_000_000L), ) - val mempoolBuckets = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, lowFeeConfig).buckets - val zeroInflows = F64Array(lowFeeConfig.arraySize) { 0.0 } + val mempoolBuckets = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, lowFeeLayout).buckets + val zeroInflows = F64Array(lowFeeLayout.arraySize) { 0.0 } val estimates = lowFeeCalculator.getFeeEstimates( @@ -269,7 +269,7 @@ class FeeEstimatesCalculatorTest { blockSize = 1.0, ) - assertEquals(defaultConfig.bucketMax + 1, result) // Index > defaultConfig.bucketMax, indicating no estimate + assertEquals(defaultLayout.bucketMax + 1, result) // Index > defaultLayout.bucketMax, indicating no estimate } @Test @@ -315,24 +315,56 @@ class FeeEstimatesCalculatorTest { } @Test - fun `test getFeeEstimates includes estimate exactly at bucketMax fee rate`() { - // Create a config with a low maxFeeRate so we can easily hit the boundary - val config = BucketConfig(maxFeeRate = 10.0) // bucketMax = floor(ln(10)*100) = 230 - val calc = FeeEstimatesCalculator(probabilities, blockTargets, config) + 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 a single bucket at bucketMax — after mining, the estimate - // should land exactly at exp(bucketMax/100) which must NOT be null - val weights = F64Array(config.arraySize) { 0.0 } - weights[0] = 4_000_000.0 // highest bucket (bucketMax) + // 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(config.arraySize) { 0.0 } + val zeroInflows = F64Array(defaultLayout.arraySize) { 0.0 } val estimates = calc.getFeeEstimates(weights, zeroInflows, zeroInflows.copy()) - // The estimate for the highest bucket should be exp(230/100) ≈ 9.97 - // which is <= maxFeeRate (10.0), so it must be non-null + // exp(1000/100) ≈ 22026 < 25000, so estimates should be non-null estimates.forEach { row -> row.forEach { fee -> - assertNotNull(fee, "Estimate at bucketMax fee rate should not be null") + assertNotNull(fee, "Estimate at or below maxFeeRate should not be null") } } } 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 40e064c..16ebec3 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/InflowCalculatorTest.kt @@ -32,15 +32,15 @@ class InflowCalculatorTest { timeframe = Duration.ofMinutes(10), ) - assertEquals(BucketConfig.DEFAULT.arraySize, 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(BucketConfig.DEFAULT.arraySize) { 1000.0 } - val buckets2 = F64Array(BucketConfig.DEFAULT.arraySize) { 2000.0 } + val buckets1 = F64Array(BucketLayout.DEFAULT.arraySize) { 1000.0 } + val buckets2 = F64Array(BucketLayout.DEFAULT.arraySize) { 2000.0 } val snapshots = listOf( @@ -56,7 +56,7 @@ class InflowCalculatorTest { // Each bucket increased by 1000, over 5 minutes // Normalized to 10 minutes, should be 2000 per bucket - assertEquals(BucketConfig.DEFAULT.arraySize, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2000.0, inflows[0]) } @@ -64,9 +64,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(BucketConfig.DEFAULT.arraySize) { 1_000_000.0 } - val buckets2 = F64Array(BucketConfig.DEFAULT.arraySize) { 2_000_000.0 } - val buckets3 = F64Array(BucketConfig.DEFAULT.arraySize) { 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 +83,9 @@ class InflowCalculatorTest { // Each bucket increased by 1M every 5 minutes // Normalized to 10 minutes, should be 2M per bucket - assertEquals(BucketConfig.DEFAULT.arraySize, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2_000_000.0, inflows[0]) - assertEquals(2_000_000.0, inflows[BucketConfig.DEFAULT.arraySize - 1]) + assertEquals(2_000_000.0, inflows[BucketLayout.DEFAULT.arraySize - 1]) } @Test @@ -94,7 +94,7 @@ class InflowCalculatorTest { // Create snapshots with different inflow rates for different buckets val buckets1 = - F64Array(BucketConfig.DEFAULT.arraySize) { idx -> + F64Array(BucketLayout.DEFAULT.arraySize) { idx -> when (idx) { 0 -> 1_000_000.0 1 -> 2_000_000.0 @@ -104,7 +104,7 @@ class InflowCalculatorTest { } val buckets2 = - F64Array(BucketConfig.DEFAULT.arraySize) { idx -> + F64Array(BucketLayout.DEFAULT.arraySize) { idx -> when (idx) { 0 -> 2_000_000.0 1 -> 4_000_000.0 @@ -129,7 +129,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(BucketConfig.DEFAULT.arraySize, 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 +142,9 @@ class InflowCalculatorTest { // Create snapshots for the same block height with fluctuating values val snapshots = listOf( - MempoolSnapshotF64Array(now, 100, F64Array(BucketConfig.DEFAULT.arraySize) { 1000.0 }), - MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 500.0 }), // Dip should be ignored - MempoolSnapshotF64Array(now.plusSeconds(300), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 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 +154,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(BucketConfig.DEFAULT.arraySize, inflows.length) + assertEquals(BucketLayout.DEFAULT.arraySize, inflows.length) assertEquals(2000.0, inflows[0]) } @@ -164,14 +164,14 @@ class InflowCalculatorTest { val snapshots = listOf( // Block 100 - MempoolSnapshotF64Array(now, 100, F64Array(BucketConfig.DEFAULT.arraySize) { 1000.0 }), - MempoolSnapshotF64Array(now.plusSeconds(100), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 500.0 }), // Should be ignored - MempoolSnapshotF64Array(now.plusSeconds(200), 100, F64Array(BucketConfig.DEFAULT.arraySize) { 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(BucketConfig.DEFAULT.arraySize) { 2000.0 }), - MempoolSnapshotF64Array(now.plusSeconds(400), 101, F64Array(BucketConfig.DEFAULT.arraySize) { 1500.0 }), // Should be ignored - MempoolSnapshotF64Array(now.plusSeconds(500), 101, F64Array(BucketConfig.DEFAULT.arraySize) { 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 +182,7 @@ class InflowCalculatorTest { // Block 100: +1000 over 200s // Block 101: +1000 over 200s // Total: +2000 over 400s = +3000 per 600s (10 minutes) - assertEquals(BucketConfig.DEFAULT.arraySize, 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 213ec63..4fe683d 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/MempoolSnapshotF64ArrayTest.kt @@ -26,12 +26,12 @@ import kotlin.test.assertTrue @OptIn(InternalAugurApi::class) class MempoolSnapshotF64ArrayTest { - private val defaultConfig = BucketConfig.DEFAULT + private val defaultLayout = BucketLayout.DEFAULT @Test fun `fromMempoolSnapshot drops buckets below minimum`() { - val lowBucket = defaultConfig.bucketMin - 1 - val validBucket = defaultConfig.bucketMin + val lowBucket = defaultLayout.bucketMin - 1 + val validBucket = defaultLayout.bucketMin val snapshot = MempoolSnapshot( blockHeight = 100, @@ -44,8 +44,8 @@ class MempoolSnapshotF64ArrayTest { val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot) - assertEquals(defaultConfig.arraySize, result.buckets.length) - val validIndex = defaultConfig.toArrayIndex(validBucket) + assertEquals(defaultLayout.arraySize, result.buckets.length) + val validIndex = defaultLayout.toArrayIndex(validBucket) assertEquals(600.0, result.buckets[validIndex]) var totalWeight = 0.0 @@ -58,8 +58,8 @@ class MempoolSnapshotF64ArrayTest { @Test fun `fromMempoolSnapshot with custom minFeeRate accepts lower buckets`() { - val config = BucketConfig(0.1) - val lowBucket = config.bucketMin + val layout = BucketLayout(0.1) + val lowBucket = layout.bucketMin assertEquals(-230, lowBucket) val validBucket = 0 // 1 sat/vB @@ -73,9 +73,9 @@ class MempoolSnapshotF64ArrayTest { ), ) - val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, config) + val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, layout) - assertEquals(config.arraySize, result.buckets.length) + assertEquals(layout.arraySize, result.buckets.length) var totalWeight = 0.0 for (i in 0 until result.buckets.length) { @@ -86,12 +86,12 @@ class MempoolSnapshotF64ArrayTest { @Test fun `fromMempoolSnapshot ignores very low fee rates below configured minimum`() { - val config = BucketConfig(0.1) + val layout = BucketLayout(0.1) val veryLowFeeRate = 0.05 val veryLowBucket = (ln(veryLowFeeRate) * 100).roundToInt() // -300 - // Verify this bucket is indeed below the config's bucketMin - assertTrue(veryLowBucket < config.bucketMin) + // Verify this bucket is indeed below the layout's bucketMin + assertTrue(veryLowBucket < layout.bucketMin) val validBucket = 0 // 1 sat/vB @@ -105,7 +105,7 @@ class MempoolSnapshotF64ArrayTest { ), ) - val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, config) + val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, layout) // Should not throw, and should only include the valid bucket's weight var totalWeight = 0.0 @@ -117,9 +117,9 @@ class MempoolSnapshotF64ArrayTest { @Test fun `fromMempoolSnapshot folds above-max buckets into highest bucket`() { - val oversizedBucket = defaultConfig.bucketMax + 1 + val oversizedBucket = defaultLayout.bucketMax + 1 // Use a bucket below bucketMax so the folded weight and valid weight land in different slots - val validBucket = defaultConfig.bucketMax - 1 + val validBucket = defaultLayout.bucketMax - 1 val snapshot = MempoolSnapshot( @@ -136,7 +136,7 @@ class MempoolSnapshotF64ArrayTest { // 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[defaultConfig.toArrayIndex(validBucket)]) + assertEquals(500.0, result.buckets[defaultLayout.toArrayIndex(validBucket)]) // Total weight is preserved var totalWeight = 0.0 for (i in 0 until result.buckets.length) { @@ -144,37 +144,4 @@ class MempoolSnapshotF64ArrayTest { } assertEquals(1500.0, totalWeight) } - - @Test - fun `fromMempoolSnapshot with custom maxFeeRate folds high-fee buckets`() { - // Use a lower maxFeeRate so some default-range buckets are above it - val config = BucketConfig(maxFeeRate = 500.0) // bucketMax = 621 - val aboveMaxBucket = 700 // above 621, should be folded - val validBucket = 600 // within range - - val snapshot = - MempoolSnapshot( - blockHeight = 100, - timestamp = Instant.now(), - bucketedWeights = mapOf( - aboveMaxBucket to 800L, - validBucket to 400L, - ), - ) - - val result = MempoolSnapshotF64Array.fromMempoolSnapshot(snapshot, config) - - assertEquals(config.arraySize, result.buckets.length) - - // Above-max weight folded into index 0 (highest bucket) - assertEquals(800.0, result.buckets[0]) - // Valid bucket at its correct position - assertEquals(400.0, result.buckets[config.toArrayIndex(validBucket)]) - - var totalWeight = 0.0 - for (i in 0 until result.buckets.length) { - totalWeight += result.buckets[i] - } - assertEquals(1200.0, totalWeight) - } } From cac7e14908d76a773fe4fab65078471063e29b26 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 11:21:56 -0700 Subject: [PATCH 12/15] Address PR review: hide minFeeRate from public API, restore < filter, fix nits - Make fromMempoolTransactions(minFeeRate) internal; public overload uses default layout only. Callers needing custom minFeeRate use createSnapshot. - Restore strict < in prepareResultArray to preserve pre-PR filter behavior. - Add assertNull to minFeeRate-above-maxFeeRate test (was a no-op loop). - Fix DEFAULT_MAX_FEE_RATE KDoc: "rounded up from exp(10)" not "~exp(10)". - Make BucketLayout.DEFAULT lazy. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/api/lib.api | 4 ++-- .../kotlin/xyz/block/augur/FeeEstimator.kt | 7 ++++--- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 20 +++++++++++++++---- .../xyz/block/augur/internal/BucketCreator.kt | 2 +- .../augur/internal/FeeEstimatesCalculator.kt | 2 +- .../xyz/block/augur/FeeEstimatorTest.kt | 6 ++++-- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/lib/api/lib.api b/lib/api/lib.api index 9b020eb..c63d217 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -74,8 +74,8 @@ public final class xyz/block/augur/MempoolSnapshot { public final class xyz/block/augur/MempoolSnapshot$Companion { public final fun empty (ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; public static synthetic fun empty$default (Lxyz/block/augur/MempoolSnapshot$Companion;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; - public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;D)Lxyz/block/augur/MempoolSnapshot; - public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;DILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; + public final fun fromMempoolTransactions (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; + public static synthetic fun fromMempoolTransactions$default (Lxyz/block/augur/MempoolSnapshot$Companion;Ljava/util/List;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } public final class xyz/block/augur/MempoolTransaction { diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index b08341f..c44e4d3 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -121,8 +121,9 @@ public class FeeEstimator @JvmOverloads public constructor( /** * Creates a [MempoolSnapshot] from raw transactions using this estimator's fee rate bounds. * - * Prefer this over [MempoolSnapshot.fromMempoolTransactions] to ensure the snapshot's bucket - * boundaries match this estimator's configuration. + * Use this instead of [MempoolSnapshot.fromMempoolTransactions] when the estimator has a + * custom [minFeeRate], to ensure the snapshot's bucket boundaries match this estimator's + * configuration. * * @param transactions List of mempool transactions * @param blockHeight Current block height @@ -214,7 +215,7 @@ public class FeeEstimator @JvmOverloads public constructor( /** * Default maximum fee rate in sat/vB for reporting. Estimates above this value are - * returned as null. Chosen as ~exp(10) to preserve the legacy simulation bucket count. + * returned as null. Rounded up from exp(10) ≈ 22026.47 to preserve the legacy simulation bucket count. */ @OptIn(InternalAugurApi::class) 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 fbfcedf..3a0c43b 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -55,9 +55,6 @@ public data class MempoolSnapshot( * @param transactions List of mempool transactions * @param blockHeight Current block height * @param timestamp When the snapshot is taken (defaults to now) - * @param minFeeRate Minimum fee rate in sat/vB for bucketing (default: 1.0). - * If using custom fee rate bounds, prefer [FeeEstimator.createSnapshot] to ensure - * the snapshot's bucket boundaries match the estimator's configuration. * @return A new [MempoolSnapshot] instance */ @OptIn(InternalAugurApi::class) @@ -65,7 +62,22 @@ public data class MempoolSnapshot( transactions: List, blockHeight: Int, timestamp: Instant = Instant.now(), - minFeeRate: Double = FeeEstimator.DEFAULT_MIN_FEE_RATE, + ): MempoolSnapshot { + val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions) + + return MempoolSnapshot( + blockHeight = blockHeight, + timestamp = timestamp, + bucketedWeights = bucketedWeights, + ) + } + + @OptIn(InternalAugurApi::class) + internal fun fromMempoolTransactions( + transactions: List, + blockHeight: Int, + timestamp: Instant = Instant.now(), + minFeeRate: Double, ): MempoolSnapshot { val bucketLayout = BucketLayout(minFeeRate) val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions, bucketLayout) 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 b73ecfa..9dd49b9 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -72,7 +72,7 @@ internal class BucketLayout( */ internal const val SIMULATION_BUCKET_MAX = 1000 - val DEFAULT = BucketLayout() + val DEFAULT by lazy { BucketLayout() } } } 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 1e58290..bb57532 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -210,7 +210,7 @@ internal class FeeEstimatesCalculator( private fun prepareResultArray(feeRates: F64Array): Array> { return Array(feeRates.shape[0]) { blockTargetIndex -> Array(feeRates.shape[1]) { probabilityIndex -> - feeRates[blockTargetIndex, probabilityIndex].takeIf { it <= maxFeeRate } + feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxFeeRate } } } } diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 73163d9..974f2c0 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -351,8 +351,10 @@ class FeeEstimatorTest { // All estimates should be null since maxFeeRate is below any possible estimate FeeEstimator.DEFAULT_BLOCK_TARGETS.forEach { target -> FeeEstimator.DEFAULT_PROBABILITIES.forEach { probability -> - val feeRate = estimate.getFeeRate(target.toInt(), probability) - // Estimates are either null or very low — this is a valid (if unusual) configuration + 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" + ) } } } From bff0276b0fb3cb0ef38e38f26039df9937ffdddd Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 11:35:01 -0700 Subject: [PATCH 13/15] Address PR review: deprecate fromMempoolTransactions, fix nits - Deprecate MempoolSnapshot.fromMempoolTransactions() in favor of FeeEstimator.createSnapshot() to prevent bucket layout mismatches - Add minFeeRate validation in FeeEstimator.init for clearer errors - Change maxFeeRate filter from < to <= (include boundary value) - Document snapshot rollover behavior when changing minFeeRate - Fix DEFAULT_MAX_FEE_RATE KDoc to not conflate output filter with simulation ceiling Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 5 ++++- lib/Module.md | 4 ++-- .../main/kotlin/xyz/block/augur/FeeEstimator.kt | 14 ++++++++++++-- .../main/kotlin/xyz/block/augur/MempoolSnapshot.kt | 8 ++++++-- .../block/augur/internal/FeeEstimatesCalculator.kt | 2 +- .../kotlin/xyz/block/augur/FeeEstimatorTest.kt | 1 + .../test/kotlin/xyz/block/augur/test/TestUtils.kt | 1 + 7 files changed, 27 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c83a666..90640d6 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,10 @@ val customFeeEstimator = FeeEstimator( 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 + // 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) diff --git a/lib/Module.md b/lib/Module.md index 6692105..e02a670 100644 --- a/lib/Module.md +++ b/lib/Module.md @@ -21,8 +21,8 @@ It analyzes mempool snapshots to generate fee rate estimates for different confi val feeEstimator = FeeEstimator() // Create a mempool snapshot from current transactions -val mempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( - transactions = currentMempoolTransactions.map { +val mempoolSnapshot = feeEstimator.createSnapshot( + transactions = currentMempoolTransactions.map { MempoolTransaction( weight = it.weight.toLong(), fee = it.baseFee // in satoshis diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index c44e4d3..ecaff09 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -46,7 +46,11 @@ import java.time.Instant * @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 to consider (default: 1.0). Set to 0.1 for * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Transactions whose fee rate - * rounds to a bucket below this threshold are excluded. + * rounds to a bucket below this threshold are excluded. **Migration note:** changing `minFeeRate` + * changes the snapshot bucket layout. Snapshots persisted under the old layout lack the new + * low-fee buckets, so estimates over a mixed history (e.g. a 24 h window) will treat the old + * snapshots as having zero weight in those buckets until the history fully rolls over. This is + * safe but means sub-`minFeeRate` data only becomes complete after one full window duration. * @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. @@ -68,6 +72,7 @@ public class FeeEstimator @JvmOverloads public constructor( 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(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } bucketLayout = BucketLayout(minFeeRate) feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate) @@ -125,6 +130,11 @@ public class FeeEstimator @JvmOverloads public constructor( * custom [minFeeRate], to ensure the snapshot's bucket boundaries match this estimator's * configuration. * + * When switching to a different [minFeeRate], all new snapshots should be created with this + * method. Previously persisted snapshots are still usable — they will simply have zero weight + * in any buckets outside their original layout — but estimates will only reflect the full fee + * rate range once the snapshot history has fully rolled over (typically 24 hours). + * * @param transactions List of mempool transactions * @param blockHeight Current block height * @param timestamp When the snapshot is taken (defaults to now) @@ -215,7 +225,7 @@ public class FeeEstimator @JvmOverloads public constructor( /** * Default maximum fee rate in sat/vB for reporting. Estimates above this value are - * returned as null. Rounded up from exp(10) ≈ 22026.47 to preserve the legacy simulation bucket count. + * returned as null. Corresponds to exp(10) ≈ 22026.47, the fee rate at the simulation ceiling. */ @OptIn(InternalAugurApi::class) 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 3a0c43b..9da7041 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -29,8 +29,8 @@ import java.time.Instant * * Example usage: * ``` - * // Create from raw mempool transactions - * val snapshot = MempoolSnapshot.fromMempoolTransactions( + * // Create from raw mempool transactions via the estimator + * val snapshot = feeEstimator.createSnapshot( * transactions = mempoolTransactions, * blockHeight = currentBlockHeight * ) @@ -57,6 +57,10 @@ public data class MempoolSnapshot( * @param timestamp When the snapshot is taken (defaults to now) * @return A new [MempoolSnapshot] instance */ + @Deprecated( + message = "Use FeeEstimator.createSnapshot() to ensure snapshot bucket layout matches the estimator's minFeeRate.", + replaceWith = ReplaceWith("feeEstimator.createSnapshot(transactions, blockHeight, timestamp)"), + ) @OptIn(InternalAugurApi::class) public fun fromMempoolTransactions( transactions: List, 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 bb57532..1e58290 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -210,7 +210,7 @@ internal class FeeEstimatesCalculator( private fun prepareResultArray(feeRates: F64Array): Array> { return Array(feeRates.shape[0]) { blockTargetIndex -> Array(feeRates.shape[1]) { probabilityIndex -> - feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxFeeRate } + feeRates[blockTargetIndex, probabilityIndex].takeIf { it <= maxFeeRate } } } } diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 974f2c0..86d571d 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -419,6 +419,7 @@ class FeeEstimatorTest { } } + @Suppress("DEPRECATION") @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 diff --git a/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt b/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt index f62a27e..69ae519 100644 --- a/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt +++ b/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt @@ -23,6 +23,7 @@ import java.time.Instant import kotlin.random.Random object TestUtils { + @Suppress("DEPRECATION") fun createSnapshot( blockHeight: Int, timestamp: Instant = Instant.now(), From ce39635baf189c4758741d436df2bc166d12b708 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 11:50:24 -0700 Subject: [PATCH 14/15] Address PR review: fix redundant allocation, improve validation and docs - Pass BucketLayout directly instead of minFeeRate in internal fromMempoolTransactions to avoid redundant object allocation - Add user-friendly upper-bound validation for minFeeRate in FeeEstimator - Clarify that snapshots are layout-agnostic; minFeeRate only affects simulation array conversion, not snapshot creation - Document ceil-vs-round boundary edge case for nonstandard minFeeRate values - Soften deprecation message to reflect actual coupling - Document intentional rounding of DEFAULT_MAX_FEE_RATE - Remove unnecessary lazy from BucketLayout.DEFAULT Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/xyz/block/augur/FeeEstimator.kt | 45 +++++++++++-------- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 6 +-- .../xyz/block/augur/internal/BucketCreator.kt | 2 +- .../augur/internal/FeeEstimatesCalculator.kt | 2 + .../xyz/block/augur/FeeEstimatorTest.kt | 8 ++++ 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index ecaff09..d463d00 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -44,13 +44,15 @@ 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 to consider (default: 1.0). Set to 0.1 for - * Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Transactions whose fee rate - * rounds to a bucket below this threshold are excluded. **Migration note:** changing `minFeeRate` - * changes the snapshot bucket layout. Snapshots persisted under the old layout lack the new - * low-fee buckets, so estimates over a mixed history (e.g. a 24 h window) will treat the old - * snapshots as having zero weight in those buckets until the history fully rolls over. This is - * safe but means sub-`minFeeRate` data only becomes complete after one full window duration. + * @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. @@ -73,6 +75,9 @@ public class FeeEstimator @JvmOverloads public constructor( 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(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } + require(minFeeRate <= MAX_SIMULATABLE_FEE_RATE) { + "minFeeRate must be at most $MAX_SIMULATABLE_FEE_RATE sat/vB, was $minFeeRate" + } require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } bucketLayout = BucketLayout(minFeeRate) feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate) @@ -124,16 +129,12 @@ public class FeeEstimator @JvmOverloads public constructor( } /** - * Creates a [MempoolSnapshot] from raw transactions using this estimator's fee rate bounds. - * - * Use this instead of [MempoolSnapshot.fromMempoolTransactions] when the estimator has a - * custom [minFeeRate], to ensure the snapshot's bucket boundaries match this estimator's - * configuration. + * Creates a [MempoolSnapshot] from raw transactions. * - * When switching to a different [minFeeRate], all new snapshots should be created with this - * method. Previously persisted snapshots are still usable — they will simply have zero weight - * in any buckets outside their original layout — but estimates will only reflect the full fee - * rate range once the snapshot history has fully rolled over (typically 24 hours). + * This is a convenience that delegates to [MempoolSnapshot.fromMempoolTransactions]. The + * snapshot itself is layout-agnostic — it stores all bucketed transactions regardless of + * [minFeeRate]. The layout only matters later, when [calculateEstimates] converts the + * snapshot into the internal simulation array. * * @param transactions List of mempool transactions * @param blockHeight Current block height @@ -148,7 +149,7 @@ public class FeeEstimator @JvmOverloads public constructor( transactions = transactions, blockHeight = blockHeight, timestamp = timestamp, - minFeeRate = minFeeRate, + bucketLayout = bucketLayout, ) /** @@ -225,9 +226,17 @@ public class FeeEstimator @JvmOverloads public constructor( /** * Default maximum fee rate in sat/vB for reporting. Estimates above this value are - * returned as null. Corresponds to exp(10) ≈ 22026.47, the fee rate at the simulation ceiling. + * returned as null. Rounded up from exp(10) ≈ 22026.47 so that estimates at the + * simulation ceiling (bucket 1000) pass the filter. */ @OptIn(InternalAugurApi::class) public val DEFAULT_MAX_FEE_RATE: Double = FeeEstimatesCalculator.DEFAULT_MAX_FEE_RATE + + /** + * Maximum fee rate the simulation can represent, exp(10) ≈ 22026.47 sat/vB. + * Used to validate [minFeeRate] — values above this have no simulatable buckets. + */ + @OptIn(InternalAugurApi::class) + private val MAX_SIMULATABLE_FEE_RATE: Double = kotlin.math.exp(BucketLayout.SIMULATION_BUCKET_MAX.toDouble() / 100) } } diff --git a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt index 9da7041..d221f92 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -58,8 +58,7 @@ public data class MempoolSnapshot( * @return A new [MempoolSnapshot] instance */ @Deprecated( - message = "Use FeeEstimator.createSnapshot() to ensure snapshot bucket layout matches the estimator's minFeeRate.", - replaceWith = ReplaceWith("feeEstimator.createSnapshot(transactions, blockHeight, timestamp)"), + message = "Prefer FeeEstimator.createSnapshot() for consistency, though the snapshot itself is layout-agnostic.", ) @OptIn(InternalAugurApi::class) public fun fromMempoolTransactions( @@ -81,9 +80,8 @@ public data class MempoolSnapshot( transactions: List, blockHeight: Int, timestamp: Instant = Instant.now(), - minFeeRate: Double, + bucketLayout: BucketLayout, ): MempoolSnapshot { - val bucketLayout = BucketLayout(minFeeRate) val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions, bucketLayout) return MempoolSnapshot( 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 9dd49b9..b73ecfa 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -72,7 +72,7 @@ internal class BucketLayout( */ internal const val SIMULATION_BUCKET_MAX = 1000 - val DEFAULT by lazy { BucketLayout() } + val DEFAULT = BucketLayout() } } 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 1e58290..c0fc89d 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -259,6 +259,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/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 86d571d..1dbf6a1 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -339,6 +339,14 @@ class FeeEstimatorTest { } } + @Test + fun `test constructor throws if minFeeRate exceeds simulation ceiling`() { + val error = assertFailsWith { + FeeEstimator(minFeeRate = 30000.0) + } + assertTrue(error.message!!.contains("at most"), "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. From 46f06043251402e181bacca8b4ca8f312d862ea1 Mon Sep 17 00:00:00 2001 From: Lauren Shareshian Date: Tue, 24 Mar 2026 12:14:12 -0700 Subject: [PATCH 15/15] Simplify internal API: remove redundant plumbing, annotations, and deprecation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove BucketLayout param from createFeeRateBuckets/calculateBucketIndex since bucketMax is fixed at 1000; clamp directly to SIMULATION_BUCKET_MAX - Delete internal fromMempoolTransactions overload that accepted BucketLayout - Move MAX_SIMULATABLE_FEE_RATE to BucketLayout companion (colocate with SIMULATION_BUCKET_MAX) - Remove redundant @InternalAugurApi from all internal classes and clean up @OptIn boilerplate — Kotlin internal visibility already prevents external access - Undeprecate fromMempoolTransactions since snapshots are layout-agnostic - Strengthen createSnapshot KDoc to explain layout-agnostic snapshot behavior - Add boundary edge-case test verifying estimates exactly equal to maxFeeRate are preserved by the <= filter Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- lib/Module.md | 2 +- lib/api/lib.api | 5 --- .../kotlin/xyz/block/augur/FeeEstimator.kt | 39 ------------------- .../kotlin/xyz/block/augur/MempoolSnapshot.kt | 26 +------------ .../xyz/block/augur/internal/BucketCreator.kt | 17 ++++---- .../augur/internal/FeeEstimatesCalculator.kt | 1 - .../block/augur/internal/InflowCalculator.kt | 1 - .../block/augur/internal/InternalAugurApi.kt | 31 --------------- .../augur/internal/MempoolSnapshotF64Array.kt | 1 - .../xyz/block/augur/FeeEstimatorTest.kt | 3 +- .../block/augur/internal/BucketCreatorTest.kt | 1 - .../internal/FeeEstimatesCalculatorTest.kt | 25 +++++++++++- .../augur/internal/InflowCalculatorTest.kt | 1 - .../internal/MempoolSnapshotF64ArrayTest.kt | 1 - .../kotlin/xyz/block/augur/test/TestUtils.kt | 1 - 16 files changed, 36 insertions(+), 121 deletions(-) delete mode 100644 lib/src/main/kotlin/xyz/block/augur/internal/InternalAugurApi.kt diff --git a/README.md b/README.md index 90640d6..f4728a9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ dependencies { val feeEstimator = FeeEstimator() // Create a mempool snapshot from current transactions -val mempoolSnapshot = feeEstimator.createSnapshot( +val mempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( transactions = currentMempoolTransactions.map { MempoolTransaction( weight = it.weight.toLong(), diff --git a/lib/Module.md b/lib/Module.md index e02a670..ad4c5d5 100644 --- a/lib/Module.md +++ b/lib/Module.md @@ -21,7 +21,7 @@ It analyzes mempool snapshots to generate fee rate estimates for different confi val feeEstimator = FeeEstimator() // Create a mempool snapshot from current transactions -val mempoolSnapshot = feeEstimator.createSnapshot( +val mempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( transactions = currentMempoolTransactions.map { MempoolTransaction( weight = it.weight.toLong(), diff --git a/lib/api/lib.api b/lib/api/lib.api index c63d217..ba6425f 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -44,8 +44,6 @@ public final class xyz/block/augur/FeeEstimator { 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;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 fun createSnapshot (Ljava/util/List;ILjava/time/Instant;)Lxyz/block/augur/MempoolSnapshot; - public static synthetic fun createSnapshot$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;ILjava/time/Instant;ILjava/lang/Object;)Lxyz/block/augur/MempoolSnapshot; } public final class xyz/block/augur/FeeEstimator$Companion { @@ -97,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 d463d00..64f1620 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -19,7 +19,6 @@ 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 @@ -57,7 +56,6 @@ import java.time.Instant * 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, @@ -74,10 +72,6 @@ public class FeeEstimator @JvmOverloads public constructor( 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(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" } - require(minFeeRate <= MAX_SIMULATABLE_FEE_RATE) { - "minFeeRate must be at most $MAX_SIMULATABLE_FEE_RATE sat/vB, was $minFeeRate" - } require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } bucketLayout = BucketLayout(minFeeRate) feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate) @@ -128,30 +122,6 @@ public class FeeEstimator @JvmOverloads public constructor( return convertToFeeEstimate(feeMatrix, orderedSnapshots.last().timestamp, targets) } - /** - * Creates a [MempoolSnapshot] from raw transactions. - * - * This is a convenience that delegates to [MempoolSnapshot.fromMempoolTransactions]. The - * snapshot itself is layout-agnostic — it stores all bucketed transactions regardless of - * [minFeeRate]. The layout only matters later, when [calculateEstimates] converts the - * snapshot into the internal simulation array. - * - * @param transactions List of mempool transactions - * @param blockHeight Current block height - * @param timestamp When the snapshot is taken (defaults to now) - * @return A new [MempoolSnapshot] instance - */ - public fun createSnapshot( - transactions: List, - blockHeight: Int, - timestamp: Instant = Instant.now(), - ): MempoolSnapshot = MempoolSnapshot.fromMempoolTransactions( - transactions = transactions, - blockHeight = blockHeight, - timestamp = timestamp, - bucketLayout = bucketLayout, - ) - /** * Creates a new [FeeEstimator] with modified settings. * @@ -221,7 +191,6 @@ public class FeeEstimator @JvmOverloads public constructor( /** * Default minimum fee rate in sat/vB. Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes. */ - @OptIn(InternalAugurApi::class) public val DEFAULT_MIN_FEE_RATE: Double = BucketLayout.DEFAULT_MIN_FEE_RATE /** @@ -229,14 +198,6 @@ public class FeeEstimator @JvmOverloads public constructor( * returned as null. Rounded up from exp(10) ≈ 22026.47 so that estimates at the * simulation ceiling (bucket 1000) pass the filter. */ - @OptIn(InternalAugurApi::class) public val DEFAULT_MAX_FEE_RATE: Double = FeeEstimatesCalculator.DEFAULT_MAX_FEE_RATE - - /** - * Maximum fee rate the simulation can represent, exp(10) ≈ 22026.47 sat/vB. - * Used to validate [minFeeRate] — values above this have no simulatable buckets. - */ - @OptIn(InternalAugurApi::class) - private val MAX_SIMULATABLE_FEE_RATE: Double = kotlin.math.exp(BucketLayout.SIMULATION_BUCKET_MAX.toDouble() / 100) } } diff --git a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt index d221f92..d975c73 100644 --- a/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt +++ b/lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt @@ -17,8 +17,6 @@ package xyz.block.augur import xyz.block.augur.internal.BucketCreator -import xyz.block.augur.internal.BucketLayout -import xyz.block.augur.internal.InternalAugurApi import java.time.Instant /** @@ -29,8 +27,8 @@ import java.time.Instant * * Example usage: * ``` - * // Create from raw mempool transactions via the estimator - * val snapshot = feeEstimator.createSnapshot( + * // Create from raw mempool transactions + * val snapshot = MempoolSnapshot.fromMempoolTransactions( * transactions = mempoolTransactions, * blockHeight = currentBlockHeight * ) @@ -57,10 +55,6 @@ public data class MempoolSnapshot( * @param timestamp When the snapshot is taken (defaults to now) * @return A new [MempoolSnapshot] instance */ - @Deprecated( - message = "Prefer FeeEstimator.createSnapshot() for consistency, though the snapshot itself is layout-agnostic.", - ) - @OptIn(InternalAugurApi::class) public fun fromMempoolTransactions( transactions: List, blockHeight: Int, @@ -75,22 +69,6 @@ public data class MempoolSnapshot( ) } - @OptIn(InternalAugurApi::class) - internal fun fromMempoolTransactions( - transactions: List, - blockHeight: Int, - timestamp: Instant = Instant.now(), - bucketLayout: BucketLayout, - ): MempoolSnapshot { - val bucketedWeights = BucketCreator.createFeeRateBuckets(transactions, bucketLayout) - - return MempoolSnapshot( - blockHeight = blockHeight, - timestamp = timestamp, - bucketedWeights = bucketedWeights, - ) - } - /** * Creates an empty mempool snapshot. * 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 b73ecfa..50db385 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt @@ -35,7 +35,6 @@ import kotlin.math.round * @property bucketMax Fixed simulation upper bound (1000) * @property arraySize Total number of bucket array slots (bucketMax - bucketMin + 1) */ -@InternalAugurApi internal class BucketLayout( minFeeRate: Double = DEFAULT_MIN_FEE_RATE, ) { @@ -79,7 +78,6 @@ internal class BucketLayout( /** * Utility functions for creating buckets from fee and weight data. */ -@InternalAugurApi internal object BucketCreator { /** * Creates a bucket map from fee and weight pairs where the key is the bucket index @@ -87,28 +85,27 @@ internal object BucketCreator { */ fun createFeeRateBuckets( feeRateWeightPairs: List, - bucketLayout: BucketLayout = BucketLayout.DEFAULT, ): Map = feeRateWeightPairs - .groupingBy { calculateBucketIndex(it.getFeeRate(), bucketLayout) } + .groupingBy { calculateBucketIndex(it.getFeeRate()) } .fold(0L) { acc, tx -> acc + tx.weight } .toSortedMap() /** * Calculates bucket index using logarithms, providing more precision in the lower fee levels. * - * Above-max fee rates are clamped to [BucketLayout.bucketMax] (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 + * 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, bucketLayout: BucketLayout): Int = min( + 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()), - bucketLayout.bucketMax, + 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 c0fc89d..7118e85 100644 --- a/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/internal/FeeEstimatesCalculator.kt @@ -28,7 +28,6 @@ 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, 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 4bbe1a1..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. 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 0b339c9..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, diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 1dbf6a1..4c0b259 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -344,7 +344,7 @@ class FeeEstimatorTest { val error = assertFailsWith { FeeEstimator(minFeeRate = 30000.0) } - assertTrue(error.message!!.contains("at most"), "Error should mention the upper bound, was: ${error.message}") + assertTrue(error.message!!.contains("too high"), "Error should mention the upper bound, was: ${error.message}") } @Test @@ -427,7 +427,6 @@ class FeeEstimatorTest { } } - @Suppress("DEPRECATION") @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 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 7e8f3f3..fe92cc3 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/BucketCreatorTest.kt @@ -25,7 +25,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue -@OptIn(InternalAugurApi::class) class BucketCreatorTest { @Test fun `test createFeeRateBuckets with single transaction`() { 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 e6ac5ab..c8f774c 100644 --- a/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/internal/FeeEstimatesCalculatorTest.kt @@ -28,7 +28,6 @@ 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) @@ -368,4 +367,28 @@ class FeeEstimatesCalculatorTest { } } } + + @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 16ebec3..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`() { 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 4fe683d..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,7 +24,6 @@ import kotlin.math.roundToInt import kotlin.test.assertEquals import kotlin.test.assertTrue -@OptIn(InternalAugurApi::class) class MempoolSnapshotF64ArrayTest { private val defaultLayout = BucketLayout.DEFAULT diff --git a/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt b/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt index 69ae519..f62a27e 100644 --- a/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt +++ b/lib/src/test/kotlin/xyz/block/augur/test/TestUtils.kt @@ -23,7 +23,6 @@ import java.time.Instant import kotlin.random.Random object TestUtils { - @Suppress("DEPRECATION") fun createSnapshot( blockHeight: Int, timestamp: Instant = Instant.now(),