Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
19f76ac
Allow users to configure minimum fee rate via FeeEstimator(minFeeRate)
laurenshareshian Mar 24, 2026
4dbc12e
Guard minFeeRate bounds and use ceil to prevent undershooting floor
laurenshareshian Mar 24, 2026
8ce8fe2
Make maximum fee rate configurable via FeeEstimator(maxFeeRate)
laurenshareshian Mar 24, 2026
3683f91
Wire maxFeeRate through fromMempoolTransactions and validate bucket r…
laurenshareshian Mar 24, 2026
d30c115
Address PR review feedback for configurable fee rate bounds
laurenshareshian Mar 24, 2026
5579a6b
Address PR review feedback: fix API compat, validation, and docs
laurenshareshian Mar 24, 2026
7046a24
Remove @JvmStatic from deprecated method, clarify maxFeeRate docs, us…
laurenshareshian Mar 24, 2026
bc3e773
Remove deprecation and unnecessary @JvmOverloads from public API
laurenshareshian Mar 24, 2026
62dd3f1
Clean up PR review nits: remove unused test var, simplify README comm…
laurenshareshian Mar 24, 2026
abb008f
Fix maxFeeRate doc precision and use assertNotNull/assertTrue consist…
laurenshareshian Mar 24, 2026
962e15d
Decouple maxFeeRate from simulation array layout
laurenshareshian Mar 24, 2026
cac7e14
Address PR review: hide minFeeRate from public API, restore < filter,…
laurenshareshian Mar 24, 2026
bff0276
Address PR review: deprecate fromMempoolTransactions, fix nits
laurenshareshian Mar 24, 2026
ce39635
Address PR review: fix redundant allocation, improve validation and docs
laurenshareshian Mar 24, 2026
46f0604
Simplify internal API: remove redundant plumbing, annotations, and de…
laurenshareshian Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,19 @@ val customFeeEstimator = FeeEstimator(
probabilities = listOf(0.1, 0.25, 0.5, 0.75, 0.9, 0.99),

// Custom block targets
blockTargets = listOf(1.0, 2.0, 3.0, 6.0, 12.0, 24.0, 48.0, 72.0)
blockTargets = listOf(1.0, 2.0, 3.0, 6.0, 12.0, 24.0, 48.0, 72.0),

// Minimum fee rate in sat/vB (default: 1.0)
// Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates.
// Changing this value changes the snapshot bucket layout. Previously persisted snapshots
// are still usable but will have zero weight in the new low-fee buckets until the
// snapshot history fully rolls over (typically 24 hours).
minFeeRate = 0.1,

// Maximum fee rate in sat/vB for reporting (default: 22027.0)
// Estimates whose fee rate exceeds this bound are returned as null.
// This is an output filter only — the simulation always models the full fee rate space.
maxFeeRate = 1000.0
)
```

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/Module.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ val feeEstimator = FeeEstimator()

// Create a mempool snapshot from current transactions
val mempoolSnapshot = MempoolSnapshot.fromMempoolTransactions(
transactions = currentMempoolTransactions.map {
transactions = currentMempoolTransactions.map {
MempoolTransaction(
weight = it.weight.toLong(),
fee = it.baseFee // in satoshis
Expand Down
13 changes: 7 additions & 6 deletions lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,19 @@ public final class xyz/block/augur/FeeEstimator {
public fun <init> (Ljava/util/List;Ljava/util/List;)V
public fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;)V
public fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;)V
public synthetic fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;D)V
public fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;DD)V
public synthetic fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;DDILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun calculateEstimates (Ljava/util/List;Ljava/lang/Double;)Lxyz/block/augur/FeeEstimate;
public static synthetic fun calculateEstimates$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;Ljava/lang/Double;ILjava/lang/Object;)Lxyz/block/augur/FeeEstimate;
public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;)Lxyz/block/augur/FeeEstimator;
public static synthetic fun configure$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;ILjava/lang/Object;)Lxyz/block/augur/FeeEstimator;
public final fun configure (Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;Ljava/lang/Double;Ljava/lang/Double;)Lxyz/block/augur/FeeEstimator;
public static synthetic fun configure$default (Lxyz/block/augur/FeeEstimator;Ljava/util/List;Ljava/util/List;Ljava/time/Duration;Ljava/time/Duration;Ljava/lang/Double;Ljava/lang/Double;ILjava/lang/Object;)Lxyz/block/augur/FeeEstimator;
}

public final class xyz/block/augur/FeeEstimator$Companion {
public final fun getDEFAULT_BLOCK_TARGETS ()Ljava/util/List;
public final fun getDEFAULT_MAX_FEE_RATE ()D
public final fun getDEFAULT_MIN_FEE_RATE ()D
public final fun getDEFAULT_PROBABILITIES ()Ljava/util/List;
}

Expand Down Expand Up @@ -91,6 +95,3 @@ public final class xyz/block/augur/MempoolTransaction {
public final class xyz/block/augur/MempoolTransaction$Companion {
}

public abstract interface annotation class xyz/block/augur/internal/InternalAugurApi : java/lang/annotation/Annotation {
}

49 changes: 42 additions & 7 deletions lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@

package xyz.block.augur

import xyz.block.augur.internal.BucketLayout
import xyz.block.augur.internal.FeeEstimatesCalculator
import xyz.block.augur.internal.InflowCalculator
import xyz.block.augur.internal.InternalAugurApi
import xyz.block.augur.internal.MempoolSnapshotF64Array
import java.time.Duration
import java.time.Instant
Expand All @@ -43,21 +43,38 @@ import java.time.Instant
*
* @property probabilities The confidence levels to calculate (default: 5%, 20%, 50%, 80%, 95%)
* @property blockTargets The block confirmation targets to estimate for (default: 3, 6, 9, 12, 18, 24, 36, 48, 72, 96, 144)
* @property minFeeRate The minimum fee rate in sat/vB for the simulation lower bound (default: 1.0).
* Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes that support sub-1 sat/vB fee rates. Snapshots
* store all bucketed transactions regardless of this value; `minFeeRate` controls which buckets
* are included when the snapshot is converted to the internal simulation array. Transactions whose
* bucket index falls below `ceil(ln(minFeeRate) * 100)` are excluded from the simulation. Note:
* because per-transaction bucketing uses `round()` while the layout boundary uses `ceil()`, a
* transaction at exactly `minFeeRate` may round to a bucket just below the boundary for some
* values; this does not affect the two standard values (1.0 and 0.1) where `ceil` and `round`
* agree.
* @property maxFeeRate The maximum fee rate in sat/vB for reporting (default: 22027.0).
* Fee estimates whose fee rate exceeds this bound are returned as null. This is an output filter
* only — the internal simulation always models the full fee rate space regardless of this value.
*/
@OptIn(InternalAugurApi::class)
public class FeeEstimator @JvmOverloads public constructor(
private val probabilities: List<Double> = DEFAULT_PROBABILITIES,
private val blockTargets: List<Double> = DEFAULT_BLOCK_TARGETS,
private val shortTermWindowDuration: Duration = Duration.ofMinutes(30),
private val longTermWindowDuration: Duration = Duration.ofHours(24),
private val minFeeRate: Double = DEFAULT_MIN_FEE_RATE,
private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE,
) {
private val feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets)
private val bucketLayout: BucketLayout
private val feeEstimatesCalculator: FeeEstimatesCalculator

init {
require(probabilities.isNotEmpty()) { "At least one probability level must be provided" }
require(blockTargets.isNotEmpty()) { "At least one block target must be provided" }
require(probabilities.all { it in 0.0..1.0 }) { "All probabilities must be between 0.0 and 1.0" }
require(blockTargets.all { it > 0 }) { "All block targets must be positive" }
require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" }
bucketLayout = BucketLayout(minFeeRate)
feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate)
}

/**
Expand All @@ -83,15 +100,15 @@ public class FeeEstimator @JvmOverloads public constructor(

// Sort the snapshots by timestamp to ensure chronological order
val orderedSnapshots = mempoolSnapshots.sortedBy { it.timestamp }
val simdSnapshots = orderedSnapshots.map { MempoolSnapshotF64Array.fromMempoolSnapshot(it) }
val simdSnapshots = orderedSnapshots.map { MempoolSnapshotF64Array.fromMempoolSnapshot(it, bucketLayout) }

// Extract latest mempool weights and calculate inflow rates
val latestMempoolWeights = simdSnapshots.last().buckets
val shortTermInflows = InflowCalculator.calculateInflows(simdSnapshots, shortTermWindowDuration)
val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration)
val shortTermInflows = InflowCalculator.calculateInflows(simdSnapshots, shortTermWindowDuration, bucketLayout)
val longTermInflows = InflowCalculator.calculateInflows(simdSnapshots, longTermWindowDuration, bucketLayout)

val (calculator, targets) = if (numOfBlocks != null) {
FeeEstimatesCalculator(probabilities, listOf(numOfBlocks)) to listOf(numOfBlocks)
FeeEstimatesCalculator(probabilities, listOf(numOfBlocks), bucketLayout, maxFeeRate) to listOf(numOfBlocks)
} else {
feeEstimatesCalculator to blockTargets
}
Expand All @@ -112,18 +129,24 @@ public class FeeEstimator @JvmOverloads public constructor(
* @param blockTargets New block targets (null to keep current)
* @param shortTermWindowDuration New short-term window duration (null to keep current)
* @param longTermWindowDuration New long-term window duration (null to keep current)
* @param minFeeRate New minimum fee rate in sat/vB (null to keep current)
* @param maxFeeRate New maximum fee rate in sat/vB (null to keep current)
* @return A new [FeeEstimator] instance with the specified settings
*/
public fun configure(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still changes the published ABI for configure(): main exposed the 4-arg overload, but this PR only leaves the new 6-arg signature in lib.api. Existing compiled Kotlin/Java callers of the old method will hit NoSuchMethodError. If this release is meant to stay binary-compatible, it needs a delegating shim that preserves the old method shape.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is intentionally not binary-compatible, which is why it's a 0.2.x → 0.3.0 bump. None of the three known consumers call configure(), and all recompile on dependency bump (this is a statically linked library, not a shared runtime dependency). Adding a delegating shim would preserve a method signature nobody calls.

probabilities: List<Double>? = null,
blockTargets: List<Double>? = null,
shortTermWindowDuration: Duration? = null,
longTermWindowDuration: Duration? = null,
minFeeRate: Double? = null,
maxFeeRate: Double? = null,
): FeeEstimator = FeeEstimator(
probabilities = probabilities ?: this.probabilities,
blockTargets = blockTargets ?: this.blockTargets,
shortTermWindowDuration = shortTermWindowDuration ?: this.shortTermWindowDuration,
longTermWindowDuration = longTermWindowDuration ?: this.longTermWindowDuration,
minFeeRate = minFeeRate ?: this.minFeeRate,
maxFeeRate = maxFeeRate ?: this.maxFeeRate,
)

/**
Expand Down Expand Up @@ -164,5 +187,17 @@ public class FeeEstimator @JvmOverloads public constructor(
* Default confidence levels for fee estimation (5%, 20%, 50%, 80%, 95%).
*/
public val DEFAULT_PROBABILITIES: List<Double> = listOf(0.05, 0.20, 0.50, 0.80, 0.95)

/**
* Default minimum fee rate in sat/vB. Set to 0.1 for Bitcoin Core 29.1/30.0+ nodes.
*/
public val DEFAULT_MIN_FEE_RATE: Double = BucketLayout.DEFAULT_MIN_FEE_RATE

/**
* Default maximum fee rate in sat/vB for reporting. Estimates above this value are
* returned as null. Rounded up from exp(10) ≈ 22026.47 so that estimates at the
* simulation ceiling (bucket 1000) pass the filter.
*/
public val DEFAULT_MAX_FEE_RATE: Double = FeeEstimatesCalculator.DEFAULT_MAX_FEE_RATE
}
}
2 changes: 0 additions & 2 deletions lib/src/main/kotlin/xyz/block/augur/MempoolSnapshot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
package xyz.block.augur

import xyz.block.augur.internal.BucketCreator
import xyz.block.augur.internal.InternalAugurApi
import java.time.Instant

/**
Expand Down Expand Up @@ -56,7 +55,6 @@ public data class MempoolSnapshot(
* @param timestamp When the snapshot is taken (defaults to now)
* @return A new [MempoolSnapshot] instance
*/
@OptIn(InternalAugurApi::class)
public fun fromMempoolTransactions(
transactions: List<MempoolTransaction>,
blockHeight: Int,
Expand Down
85 changes: 61 additions & 24 deletions lib/src/main/kotlin/xyz/block/augur/internal/BucketCreator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,58 +17,95 @@
package xyz.block.augur.internal

import xyz.block.augur.MempoolTransaction
import kotlin.math.ceil
import kotlin.math.ln
import kotlin.math.min
import kotlin.math.round

/**
* Utility functions for creating buckets from fee and weight data.
* Internal simulation array layout derived from the minimum fee rate.
*
* The array always extends to a fixed upper bound ([SIMULATION_BUCKET_MAX] = 1000, corresponding
* to ~22026 sat/vB). The user-facing `maxFeeRate` is applied as an output filter in
* [FeeEstimatesCalculator.prepareResultArray], not as an array sizing parameter.
*
* Uses ceil for [bucketMin] so the lowest bucket never represents a fee rate below [minFeeRate].
*
* @property bucketMin Minimum bucket index, computed as ceil(ln(minFeeRate) * 100)
* @property bucketMax Fixed simulation upper bound (1000)
* @property arraySize Total number of bucket array slots (bucketMax - bucketMin + 1)
*/
@InternalAugurApi
internal object BucketCreator {
/**
* Maximum bucket index.
*/
const val BUCKET_MAX = 1000

/**
* Minimum bucket index corresponding to 0.1 sat/vByte (Bitcoin Core 29.1/30.0+).
* Calculated as round(ln(0.1) * 100) = -230
*/
const val BUCKET_MIN = -230
internal class BucketLayout(
minFeeRate: Double = DEFAULT_MIN_FEE_RATE,
) {
val bucketMin: Int
val bucketMax: Int = SIMULATION_BUCKET_MAX
val arraySize: Int

/**
* Total number of bucket array slots needed to store buckets from BUCKET_MIN to BUCKET_MAX.
* Size = BUCKET_MAX - BUCKET_MIN + 1 = 1000 - (-230) + 1 = 1231
*/
const val BUCKET_ARRAY_SIZE = BUCKET_MAX - BUCKET_MIN + 1
init {
require(minFeeRate > 0.0) { "minFeeRate must be positive, was $minFeeRate" }
bucketMin = ceil(ln(minFeeRate) * 100).toInt()
require(bucketMin <= bucketMax) {
"minFeeRate ($minFeeRate) is too high: bucketMin ($bucketMin) exceeds simulation ceiling ($bucketMax)"
}
arraySize = bucketMax - bucketMin + 1
}

/**
* Converts a bucket index (BUCKET_MIN..BUCKET_MAX) to the corresponding array position.
* Buckets are stored in reverse order so that the highest fee rate (BUCKET_MAX) is at index 0.
* Converts a bucket index (bucketMin..bucketMax) to the corresponding array position.
* Buckets are stored in reverse order so that the highest fee rate (bucketMax) is at index 0.
*/
fun toArrayIndex(bucket: Int): Int = BUCKET_MAX - bucket
fun toArrayIndex(bucket: Int): Int = bucketMax - bucket

/**
* Converts an array position back to the original bucket index.
*/
fun toBucketIndex(arrayIndex: Int): Int = BUCKET_MAX - arrayIndex
fun toBucketIndex(arrayIndex: Int): Int = bucketMax - arrayIndex

companion object {
internal const val DEFAULT_MIN_FEE_RATE = 1.0

/**
* Fixed simulation upper bound. Corresponds to floor(ln(22027) * 100) = 1000,
* preserving the legacy bucket count.
*/
internal const val SIMULATION_BUCKET_MAX = 1000

val DEFAULT = BucketLayout()
}
}

/**
* Utility functions for creating buckets from fee and weight data.
*/
internal object BucketCreator {
/**
* Creates a bucket map from fee and weight pairs where the key is the bucket index
* and the value is the sum of the weights at that fee rate, normalized to a one block duration.
*/
fun createFeeRateBuckets(feeRateWeightPairs: List<MempoolTransaction>): Map<Int, Long> =
fun createFeeRateBuckets(
feeRateWeightPairs: List<MempoolTransaction>,
): Map<Int, Long> =
feeRateWeightPairs
.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.SIMULATION_BUCKET_MAX] (the fixed simulation
* ceiling) so their block weight is preserved in the highest bucket. Below-min fee rates are
* intentionally NOT clamped here; they produce indices below [BucketLayout.bucketMin] and are
* dropped by [MempoolSnapshotF64Array.fromMempoolSnapshot], since sub-relay-minimum transactions
* should not influence fee estimates.
*/
private fun calculateBucketIndex(feeRate: Double): Int = min(
// round() is correct here: each transaction maps to its nearest bucket.
// BucketLayout uses ceil for the lower *boundary* to guarantee the range stays within the
// user's configured min fee rate, but individual transactions should snap to the
// closest discrete bucket rather than being biased up or down.
(round(ln(feeRate) * 100).toInt()),
BUCKET_MAX,
BucketLayout.SIMULATION_BUCKET_MAX,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@ package xyz.block.augur.internal
import org.apache.commons.math3.distribution.PoissonDistribution
import org.jetbrains.bio.viktor.F64Array
import org.jetbrains.bio.viktor.F64Array.Companion.invoke
import xyz.block.augur.internal.BucketCreator.BUCKET_MAX
import xyz.block.augur.internal.BucketCreator.BUCKET_MIN
import kotlin.math.exp
import kotlin.math.min
import kotlin.math.pow

Expand All @@ -31,10 +28,11 @@ import kotlin.math.pow
* This class simulates the mining of blocks to predict when transactions
* with different fee rates would be confirmed.
*/
@InternalAugurApi
internal class FeeEstimatesCalculator(
private val probabilities: List<Double>,
private val blockTargets: List<Double>,
private val bucketLayout: BucketLayout = BucketLayout.DEFAULT,
private val maxFeeRate: Double = DEFAULT_MAX_FEE_RATE,
) {
private val expectedBlocksMined by lazy { getExpectedBlocksMined() }

Expand All @@ -45,7 +43,7 @@ internal class FeeEstimatesCalculator(
* @param shortIntervalInflows Short-term inflow data (typically 30 minutes)
* @param longIntervalInflows Long-term inflow data (typically 24 hours)
* @return A 2D array of fee estimates where each element corresponds to a specific
* block target and probability level. Values exceeding the max bucket threshold are null.
* block target and probability level. Values exceeding [maxFeeRate] are null.
*/
fun getFeeEstimates(
mempoolSnapshot: F64Array,
Expand Down Expand Up @@ -174,9 +172,9 @@ internal class FeeEstimatesCalculator(
// If index = -1, then no weights are fully mined so can't determine a sufficiently high rate.
// Else, createFeeRateBuckets reversed the order, so subtract to recover the original index.
return when (index) {
-2 -> BUCKET_MIN // all weights are zero so we can use the cheapest fee rate
-1 -> BUCKET_MAX + 1 // return null
else -> BucketCreator.toBucketIndex(index)
-2 -> bucketLayout.bucketMin // all weights are zero so we can use the cheapest fee rate
-1 -> bucketLayout.bucketMax + 1 // return null
else -> bucketLayout.toBucketIndex(index)
}
}

Expand Down Expand Up @@ -209,12 +207,9 @@ internal class FeeEstimatesCalculator(
* F64Array can't accommodate nulls so we convert to traditional arrays.
*/
private fun prepareResultArray(feeRates: F64Array): Array<Array<Double?>> {
// Maximum allowed fee rate based on the BUCKET_MAX constant
val maxAllowedFeeRate = exp(BUCKET_MAX.toDouble() / 100)

return Array(feeRates.shape[0]) { blockTargetIndex ->
Array(feeRates.shape[1]) { probabilityIndex ->
feeRates[blockTargetIndex, probabilityIndex].takeIf { it < maxAllowedFeeRate }
feeRates[blockTargetIndex, probabilityIndex].takeIf { it <= maxFeeRate }
}
}
}
Expand Down Expand Up @@ -263,5 +258,8 @@ internal class FeeEstimatesCalculator(

companion object {
const val BLOCK_SIZE_WEIGHT_UNITS = 4_000_000

// Rounded up from exp(10) ≈ 22026.47 so estimates at the simulation ceiling pass the <= filter
const val DEFAULT_MAX_FEE_RATE = 22027.0
}
}
Loading
Loading