From 31fab117d32aef94dc7d686d90f7d161bdac2d38 Mon Sep 17 00:00:00 2001 From: kevkevinpal Date: Fri, 8 Aug 2025 19:09:44 -0400 Subject: [PATCH 1/3] feat: Added new endpoint GET /historical_fee?date=YYYY-MM-DDTHH:MM:SS Signed-off-by: kevkevinpal --- .../augurref/api/HistoricalFeeEndpoint.kt | 79 +++++++++++++++++++ .../xyz/block/augurref/server/HttpServer.kt | 2 + .../augurref/service/MempoolCollector.kt | 32 ++++++++ 3 files changed, 113 insertions(+) create mode 100644 app/src/main/kotlin/xyz/block/augurref/api/HistoricalFeeEndpoint.kt diff --git a/app/src/main/kotlin/xyz/block/augurref/api/HistoricalFeeEndpoint.kt b/app/src/main/kotlin/xyz/block/augurref/api/HistoricalFeeEndpoint.kt new file mode 100644 index 0000000..c7afd2b --- /dev/null +++ b/app/src/main/kotlin/xyz/block/augurref/api/HistoricalFeeEndpoint.kt @@ -0,0 +1,79 @@ +/* + * 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.augurref.api + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import org.slf4j.LoggerFactory +import xyz.block.augurref.service.MempoolCollector + +/** + * Configure historical fee estimate endpoint + */ +fun Route.configureHistoricalFeesEndpoint(mempoolCollector: MempoolCollector) { + val logger = LoggerFactory.getLogger("xyz.block.augurref.api.HistoricalFeeEstimateEndpoint") + + get("/historical_fee") { + logger.info("Received request for historical fee estimates") + + // Extract unix timestamp param from query parameters + val timestampParam = call.request.queryParameters["timestamp"] + + if (timestampParam == null) { + call.respondText( + "timestamp parameter is required", + status = HttpStatusCode.BadRequest, + contentType = ContentType.Text.Plain, + ) + return@get + } + val timestamp = timestampParam.toLongOrNull() + + // Fetch historical fee estimate based on unix timestamp + val feeEstimate = if (timestamp != null) { + logger.info("Fetching historical fee estimate for timestamp: $timestamp") + mempoolCollector.getFeeEstimateForTimestamp(timestamp) + } else { + logger.warn("timestamp is null") + call.respondText( + "Failed to parse timestamp, please input a unix timestamp", + status = HttpStatusCode.BadRequest, + contentType = ContentType.Text.Plain, + ) + return@get + } + + if (feeEstimate == null) { + logger.warn("No historical fee estimates available for $timestamp") + call.respondText( + "No historical fee estimates available for $timestamp", + status = HttpStatusCode.ServiceUnavailable, + contentType = ContentType.Text.Plain, + ) + } else { + logger.info("Transforming historical fee estimates for response") + val response = transformFeeEstimate(feeEstimate) + logger.debug("Returning historical fee estimates with ${response.estimates.size} targets") + call.respond(response) + } + } +} diff --git a/app/src/main/kotlin/xyz/block/augurref/server/HttpServer.kt b/app/src/main/kotlin/xyz/block/augurref/server/HttpServer.kt index dfdcbc9..796edeb 100644 --- a/app/src/main/kotlin/xyz/block/augurref/server/HttpServer.kt +++ b/app/src/main/kotlin/xyz/block/augurref/server/HttpServer.kt @@ -26,6 +26,7 @@ import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.routing.routing import org.slf4j.LoggerFactory import xyz.block.augurref.api.configureFeesEndpoint +import xyz.block.augurref.api.configureHistoricalFeesEndpoint import xyz.block.augurref.config.ServerConfig import xyz.block.augurref.service.MempoolCollector @@ -58,6 +59,7 @@ class HttpServer( // Configure routes routing { configureFeesEndpoint(mempoolCollector) + configureHistoricalFeesEndpoint(mempoolCollector) } }.start(wait = false) diff --git a/app/src/main/kotlin/xyz/block/augurref/service/MempoolCollector.kt b/app/src/main/kotlin/xyz/block/augurref/service/MempoolCollector.kt index afcc719..198462b 100644 --- a/app/src/main/kotlin/xyz/block/augurref/service/MempoolCollector.kt +++ b/app/src/main/kotlin/xyz/block/augurref/service/MempoolCollector.kt @@ -24,6 +24,7 @@ import xyz.block.augurref.bitcoin.BitcoinRpcClient import xyz.block.augurref.persistence.MempoolPersistence import java.time.Instant import java.time.LocalDateTime +import java.time.ZoneId import java.util.concurrent.atomic.AtomicReference import kotlin.concurrent.fixedRateTimer @@ -71,6 +72,37 @@ class MempoolCollector( return latestFeeEstimate.get() } + /** + * Get the fee estimate for specific date + */ + fun getFeeEstimateForTimestamp(unixTimestamp: Long): FeeEstimate? { + val dateTime = Instant + .ofEpochSecond(unixTimestamp) + .atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime() + // Fetch the last day's snapshots + logger.debug("Fetching snapshots from the last day") + val lastDaySnapshots = persistence.getSnapshots( + dateTime.minusDays(1), + dateTime, + ) + logger.debug("Retrieved ${lastDaySnapshots.size} snapshots from the last day") + + if (lastDaySnapshots.isNotEmpty()) { + // Calculate fee estimate for x blocks + logger.debug("Calculating fee estimates") + val newFeeEstimate = feeEstimator.calculateEstimates(lastDaySnapshots) + return newFeeEstimate + } else { + logger.warn("No snapshots available for fee estimation") + } + return FeeEstimate( + estimates = emptyMap(), + timestamp = dateTime.atZone(ZoneId.of("UTC")).toInstant(), + ) + } + /** * Get the latest fee estimate for block target */ From 7b75f4423f13739b965eab1564a93af0c2fb2304 Mon Sep 17 00:00:00 2001 From: kevkevinpal Date: Sat, 16 Aug 2025 15:49:11 -0400 Subject: [PATCH 2/3] test: added Historical Fee Estimation Endpoint test Signed-off-by: kevkevinpal --- app/build.gradle.kts | 4 + .../api/HistoricalFeeEstimateEndpointTest.kt | 181 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 app/src/test/kotlin/xyz/block/augurref/api/HistoricalFeeEstimateEndpointTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 058c647..1a1df48 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,6 +57,10 @@ dependencies { testImplementation(libs.ktor.client.core) testImplementation(libs.ktor.client.cio) testImplementation(libs.ktor.client.content.negotiation) + + // MockK for mocking dependencies + testImplementation("io.mockk:mockk:1.13.8") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0") } // Apply a specific Java toolchain to ease working on different environments. diff --git a/app/src/test/kotlin/xyz/block/augurref/api/HistoricalFeeEstimateEndpointTest.kt b/app/src/test/kotlin/xyz/block/augurref/api/HistoricalFeeEstimateEndpointTest.kt new file mode 100644 index 0000000..3126dcb --- /dev/null +++ b/app/src/test/kotlin/xyz/block/augurref/api/HistoricalFeeEstimateEndpointTest.kt @@ -0,0 +1,181 @@ +/* + * 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.augurref.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.jackson.jackson +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.TestApplicationBuilder +import io.ktor.server.testing.testApplication +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import xyz.block.augur.FeeEstimate +import xyz.block.augurref.service.MempoolCollector +import java.time.Instant +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FeeEstimateEndpointTest { + private val mockMempoolCollector = mockk() + private val objectMapper = ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + registerModule(JavaTimeModule()) + } + + companion object { + object TestData { + object FeeRates { + const val LOW_BLOCK1 = 10.5 + const val HIGH_BLOCK1 = 15.25 + const val LOW_BLOCK6 = 5.75 + const val HIGH_BLOCK6 = 8.1234 + } + + object Probabilities { + const val MEDIUM = 0.5 + const val HIGH = 0.9 + } + + object BlockTargets { + const val TARGET_1 = 1 + const val TARGET_6 = 6 + } + } + } + + /** + * Configures the test application with the same JSON serialization + * settings as the main server and sets up the fees endpoint routing. + */ + private fun TestApplicationBuilder.configureTestApplication() { + application { + // Configure JSON serialization (same as main server) + install(ContentNegotiation) { + jackson { + registerModule(JavaTimeModule()) + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + enable(SerializationFeature.INDENT_OUTPUT) + } + } + + routing { + configureHistoricalFeesEndpoint(mockMempoolCollector) + } + } + } + + @Test + fun `should return historical fee estimates when available`() = testApplication { + val fixedInstant = Instant.parse("2025-01-15T10:00:00.123Z") + val fixedInstantUnixTimestamp = fixedInstant.toEpochMilli() + val mockFeeEstimate = createMockFeeEstimate(fixedInstant) + every { mockMempoolCollector.getFeeEstimateForTimestamp(fixedInstantUnixTimestamp) } returns mockFeeEstimate + + configureTestApplication() + + client.get("/historical_fee?timestamp=$fixedInstantUnixTimestamp").apply { + assertEquals(HttpStatusCode.OK, status) + val responseBody = bodyAsText() + val response: FeeEstimateResponse = objectMapper.readValue(responseBody) + + assertEquals(fixedInstant, response.mempoolUpdateTime) + assertEquals(2, response.estimates.size) + assertTrue(response.estimates.containsKey("1")) + assertTrue(response.estimates.containsKey("6")) + + val firstBlock = response.estimates["1"]!! + assertEquals(2, firstBlock.probabilities.size) + assertEquals(TestData.FeeRates.LOW_BLOCK1, firstBlock.probabilities["0.50"]?.feeRate) + assertEquals(TestData.FeeRates.HIGH_BLOCK1, firstBlock.probabilities["0.90"]?.feeRate) + } + + verify { mockMempoolCollector.getFeeEstimateForTimestamp(fixedInstantUnixTimestamp) } + } + + @Test + fun `should return 503 when no historical estimates available`() = testApplication { + val fixedInstant = Instant.parse("2025-01-15T10:00:00.123Z") + val fixedInstantUnixTimestamp = fixedInstant.toEpochMilli() + every { mockMempoolCollector.getFeeEstimateForTimestamp(fixedInstantUnixTimestamp) } returns null + + configureTestApplication() + + client.get("/historical_fee?timestamp=$fixedInstantUnixTimestamp").apply { + assertEquals(HttpStatusCode.ServiceUnavailable, status) + assertEquals("No historical fee estimates available for $fixedInstantUnixTimestamp", bodyAsText()) + } + + verify { mockMempoolCollector.getFeeEstimateForTimestamp(fixedInstantUnixTimestamp) } + } + + @Test + fun `should return 400 when timestamp param is malformed or null`() = testApplication { + val fixedInstant = Instant.parse("2025-01-15T10:00:00.123Z") + val fixedInstantUnixTimestamp = fixedInstant.toEpochMilli() + every { mockMempoolCollector.getFeeEstimateForTimestamp(fixedInstantUnixTimestamp) } returns null + + configureTestApplication() + + client.get("/historical_fee?timestamp=$fixedInstant").apply { + assertEquals(HttpStatusCode.BadRequest, status) + assertEquals("Failed to parse timestamp, please input a unix timestamp", bodyAsText()) + } + client.get("/historical_fee").apply { + assertEquals(HttpStatusCode.BadRequest, status) + assertEquals("timestamp parameter is required", bodyAsText()) + } + } + + private fun createMockFeeEstimate(timestamp: Instant): FeeEstimate { + // Create mock data using the actual augur library structure + val mockFeeEstimate = mockk() + + // Mock the properties that are accessed in the transformation + every { mockFeeEstimate.timestamp } returns timestamp + + // Create mock estimates map - this is what the transformation function expects + val mockBlockTarget1 = mockk() + every { mockBlockTarget1.probabilities } returns mapOf( + TestData.Probabilities.MEDIUM to TestData.FeeRates.LOW_BLOCK1, + TestData.Probabilities.HIGH to TestData.FeeRates.HIGH_BLOCK1, + ) + + val mockBlockTarget6 = mockk() + every { mockBlockTarget6.probabilities } returns mapOf( + TestData.Probabilities.MEDIUM to TestData.FeeRates.LOW_BLOCK6, + TestData.Probabilities.HIGH to TestData.FeeRates.HIGH_BLOCK6, + ) + + every { mockFeeEstimate.estimates } returns mapOf( + TestData.BlockTargets.TARGET_1 to mockBlockTarget1, + TestData.BlockTargets.TARGET_6 to mockBlockTarget6, + ) + + return mockFeeEstimate + } +} From 473787b0e9134d6082ba914657242bf7177f09c8 Mon Sep 17 00:00:00 2001 From: kevkevinpal Date: Sat, 16 Aug 2025 19:53:04 -0400 Subject: [PATCH 3/3] doc: Added historical request to the README.md Signed-off-by: kevkevinpal --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/README.md b/README.md index f667f13..e29b720 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,81 @@ Example response: } ``` + +Historical fee estimates at: + +``` +GET /historical_fee?timestamp= +``` + +Example response: +```json + +{ + "mempool_update_time" : "2025-08-16T14:05:44.854Z", + "estimates" : { + "3" : { + "probabilities" : { + "0.05" : { + "fee_rate" : 1.0 + }, + "0.20" : { + "fee_rate" : 1.0 + }, + "0.50" : { + "fee_rate" : 1.0 + }, + "0.80" : { + "fee_rate" : 1.0 + }, + "0.95" : { + "fee_rate" : 1.0 + } + } + }, + "6" : { + "probabilities" : { + "0.05" : { + "fee_rate" : 1.0 + }, + "0.20" : { + "fee_rate" : 1.0 + }, + "0.50" : { + "fee_rate" : 1.0 + }, + "0.80" : { + "fee_rate" : 1.0 + }, + "0.95" : { + "fee_rate" : 1.0 + } + } + }, + "9" : { + "probabilities" : { + "0.05" : { + "fee_rate" : 1.0 + }, + "0.20" : { + "fee_rate" : 1.0 + }, + "0.50" : { + "fee_rate" : 1.0 + }, + "0.80" : { + "fee_rate" : 1.0 + }, + "0.95" : { + "fee_rate" : 1.0 + } + } + } + } +} + +``` + ## Local Development If you'd like to use a local version of [augur](https://github.com/block/bitcoin-augur) within your reference implementation: - Within augur, run `bin/gradle shadowJar` to build a fat jar of augur.