From e824ab158420bb74ffddcb5e7ec61b2949c09fc4 Mon Sep 17 00:00:00 2001 From: Sanket Kanjalkar Date: Wed, 5 Aug 2026 11:03:18 -0700 Subject: [PATCH] Bound the block target in calculateEstimates and the constructor Simulation cost grows with the block target: getExpectedBlocksMined evaluates a Poisson tail of 4 * target entries and then simulates that many blocks, so a target of 1_000_000 spends roughly two minutes of CPU. Neither the numOfBlocks argument nor the blockTargets constructor list had an upper bound, and bitcoin-augur-server passes an HTTP query parameter straight into numOfBlocks, so one unauthenticated request can saturate a core. Reject targets above MAX_BLOCK_TARGET, one week of blocks. --- lib/api/lib.api | 1 + .../kotlin/xyz/block/augur/FeeEstimator.kt | 16 +++++++++++ .../xyz/block/augur/FeeEstimatorTest.kt | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/lib/api/lib.api b/lib/api/lib.api index ba6425f..f5aede3 100644 --- a/lib/api/lib.api +++ b/lib/api/lib.api @@ -32,6 +32,7 @@ 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 MAX_BLOCK_TARGET D public fun ()V public fun (Ljava/util/List;)V public fun (Ljava/util/List;Ljava/util/List;)V diff --git a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt index 64f1620..c250f0a 100644 --- a/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt +++ b/lib/src/main/kotlin/xyz/block/augur/FeeEstimator.kt @@ -72,6 +72,10 @@ 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" } + // Same cost bound as numOfBlocks below: a target of a million blocks simulates a million blocks. + require(blockTargets.all { it <= MAX_BLOCK_TARGET }) { + "All block targets must be at most $MAX_BLOCK_TARGET, was ${blockTargets.filter { it > MAX_BLOCK_TARGET }}" + } require(maxFeeRate > 0.0) { "maxFeeRate must be positive, was $maxFeeRate" } bucketLayout = BucketLayout(minFeeRate) feeEstimatesCalculator = FeeEstimatesCalculator(probabilities, blockTargets, bucketLayout, maxFeeRate) @@ -93,6 +97,12 @@ public class FeeEstimator @JvmOverloads public constructor( // If numOfBlocks is specified then it needs to be at least 3, // since we can't simulate partial blocks being mined require(numOfBlocks == null || numOfBlocks >= 3.0) { "numOfBlocks must be at least 3 if specified" } + // It also needs an upper bound: getExpectedBlocksMined evaluates a Poisson tail of + // 4 * numOfBlocks entries and then simulates that many blocks, so an unbounded value costs + // minutes of CPU per call -- and callers behind an HTTP service pass a request parameter through. + require(numOfBlocks == null || numOfBlocks <= MAX_BLOCK_TARGET) { + "numOfBlocks must be at most $MAX_BLOCK_TARGET if specified, was $numOfBlocks" + } if (mempoolSnapshots.isEmpty()) { return FeeEstimate(emptyMap(), Instant.now()) @@ -199,5 +209,11 @@ public class FeeEstimator @JvmOverloads public constructor( * simulation ceiling (bucket 1000) pass the filter. */ public val DEFAULT_MAX_FEE_RATE: Double = FeeEstimatesCalculator.DEFAULT_MAX_FEE_RATE + + /** + * Largest supported block target, one week of blocks. Simulation cost grows linearly with the + * target, so this bound keeps a caller-supplied `numOfBlocks` from costing minutes of CPU. + */ + public const val MAX_BLOCK_TARGET: Double = 1008.0 } } diff --git a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt index 4c0b259..9fc3974 100644 --- a/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt +++ b/lib/src/test/kotlin/xyz/block/augur/FeeEstimatorTest.kt @@ -329,6 +329,34 @@ class FeeEstimatorTest { } } + @Test + fun `test calculateEstimates throws if numOfBlocks exceeds the maximum block target`() { + // Simulation cost grows with the target, so an unbounded value burns minutes of CPU in a single + // call -- and callers behind an HTTP service pass a request parameter straight through. + val snapshots = TestUtils.createSnapshotSequence(blockCount = 5, snapshotsPerBlock = 3) + assertFailsWith { + feeEstimator.calculateEstimates(snapshots, numOfBlocks = 1_000_000.0) + } + assertFailsWith { + feeEstimator.calculateEstimates(snapshots, numOfBlocks = FeeEstimator.MAX_BLOCK_TARGET + 1) + } + // The bound itself is still accepted. + feeEstimator.calculateEstimates(snapshots, numOfBlocks = FeeEstimator.MAX_BLOCK_TARGET) + } + + @Test + fun `test constructor throws if a block target exceeds the maximum`() { + // blockTargets reaches the same simulation cost as numOfBlocks, so it needs the same bound. + assertFailsWith { + FeeEstimator(blockTargets = listOf(3.0, 1_000_000.0)) + } + assertFailsWith { + feeEstimator.configure(blockTargets = listOf(FeeEstimator.MAX_BLOCK_TARGET + 1)) + } + // The bound itself is still accepted. + FeeEstimator(blockTargets = listOf(FeeEstimator.MAX_BLOCK_TARGET)) + } + @Test fun `test constructor throws if minFeeRate is zero or negative`() { assertFailsWith {