From a859ea032960c90a5009a647153db1ae006551ce Mon Sep 17 00:00:00 2001 From: ismaelsadeeq Date: Thu, 30 Apr 2026 13:50:35 +0100 Subject: [PATCH] rpc: adapt estimate_smart_fee to new estimatesmartfee RPC signature --- Readme.md | 5 +++ backend/src/app.py | 3 +- backend/src/services/rpc_client.py | 21 ++++++--- backend/src/services/rpc_service.py | 5 ++- backend/tests/test_app.py | 2 +- backend/tests/test_rpc_service.py | 56 ++++++++++++++++++++---- frontend/src/components/home/FeeCard.tsx | 5 +++ frontend/src/services/api.test.ts | 2 +- frontend/src/services/api.ts | 4 +- frontend/src/types/api.ts | 2 + 10 files changed, 83 insertions(+), 22 deletions(-) diff --git a/Readme.md b/Readme.md index 6c202fa..057d4c0 100644 --- a/Readme.md +++ b/Readme.md @@ -103,6 +103,10 @@ Returns a fee rate estimate from Bitcoin Core's `estimatesmartfee`. | `mode` | string | `economical`, `conservative`, or `unset` | | `level` | int | Verbosity level passed to `estimatesmartfee` (PR #34075) | +| Query param | Default | Description | +|----------------------|---------|-------------| +| `block_policy_only` | `false` | When `true`, restricts estimation to the block policy estimator only. When `false`, Bitcoin Core may use additional estimators and includes an `estimator` field in the response naming the one chosen. | + ``` GET /fees/2/economical/2?chain=signet ``` @@ -112,6 +116,7 @@ GET /fees/2/economical/2?chain=signet "feerate": 0.00001000, "feerate_sat_per_vb": 1.0, "blocks": 2, + "estimator": "mempool", "chain": "signet" } ``` diff --git a/backend/src/app.py b/backend/src/app.py index 2a5d7d5..fb4de4c 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -63,8 +63,9 @@ def fees(target, mode, level): chain, err = _resolve_chain() if err: return err + block_policy_only = request.args.get("block_policy_only", "false").lower() != "false" try: - result = rpc_service.estimate_smart_fee(conf_target=target, mode=mode, verbosity_level=level, chain=chain) + result = rpc_service.estimate_smart_fee(conf_target=target, mode=mode, verbosity=level, block_policy_only=block_policy_only, chain=chain) return jsonify(result) except Exception as e: logger.error(f"/fees RPC failed: {e}", exc_info=True) diff --git a/backend/src/services/rpc_client.py b/backend/src/services/rpc_client.py index ae08d42..45e5758 100644 --- a/backend/src/services/rpc_client.py +++ b/backend/src/services/rpc_client.py @@ -120,14 +120,23 @@ def get_blockchain_info(self) -> Dict[str, Any]: display = CHAIN_DISPLAY_NAMES.get(chain, chain.upper()) return {"chain": chain, "chain_display": display, "blockcount": blocks} - def estimate_smart_fee(self, conf_target: int, mode: str = "unset", verbosity_level: int = 2) -> Dict[str, Any]: + def estimate_smart_fee( + self, + conf_target: int, + mode: str = "unset", + verbosity: int = 2, + block_policy_only: bool = False, + ) -> Dict[str, Any]: """Call estimatesmartfee and annotate the result with sat/vB conversion. The raw feerate is BTC/kvB. Conversion: BTC/kvB × 100,000,000 sat/BTC ÷ 1,000 vB/kvB = sat/vB × 100,000 + + With block_policy_only=False (our default) the node may use additional + estimators and returns an ``estimator`` field naming the one chosen. """ effective_target = _clamp_target(conf_target) - result = self.rpc_call("estimatesmartfee", [effective_target, mode, verbosity_level]) + result = self.rpc_call("estimatesmartfee", [effective_target, mode, verbosity, block_policy_only]) if result and "feerate" in result: result["feerate_sat_per_vb"] = result["feerate"] * 100_000 if result is not None: @@ -137,12 +146,10 @@ def estimate_smart_fee(self, conf_target: int, mode: str = "unset", verbosity_le def get_mempool_health_statistics(self) -> List[Dict[str, Any]]: """Return per-block mempool health stats via estimatesmartfee verbosity 2. - verbosity=2 (Bitcoin Core PR #34075) includes mempool_health_statistics - directly in the response, avoiding a separate RPC round-trip. - mempool_txs_weight is the weight of mempool transactions projected into - each block — not the live mempool total. + verbosity=2 includes mempool_health_statistics in the response, but only + when block_policy_only=False. """ - result = self.rpc_call("estimatesmartfee", [2, "unset", 2]) + result = self.rpc_call("estimatesmartfee", [2, "unset", 2, False]) if not result: return [] raw_stats = result.get("mempool_health_statistics", []) diff --git a/backend/src/services/rpc_service.py b/backend/src/services/rpc_service.py index 9aa66ff..7b7e5ee 100644 --- a/backend/src/services/rpc_service.py +++ b/backend/src/services/rpc_service.py @@ -77,10 +77,11 @@ def get_blockchain_info(chain: Optional[str] = None) -> Dict[str, Any]: def estimate_smart_fee( conf_target: int, mode: str = "unset", - verbosity_level: int = 2, + verbosity: int = 2, + block_policy_only: bool = False, chain: Optional[str] = None, ) -> Dict[str, Any]: - return get_client(chain).estimate_smart_fee(conf_target, mode, verbosity_level) + return get_client(chain).estimate_smart_fee(conf_target, mode, verbosity, block_policy_only) def get_mempool_feerate_diagram_analysis(chain: Optional[str] = None) -> Dict[str, Any]: diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 4592729..447fbda 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -111,7 +111,7 @@ def test_fees_passes_chain_param(self): with patch('services.rpc_service._get_registry', return_value=mock_reg), \ patch('services.rpc_service.estimate_smart_fee', return_value={"feerate": 0.0001}) as mock: self.client.get('/fees/2/economical/2?chain=signet') - mock.assert_called_once_with(conf_target=2, mode='economical', verbosity_level=2, chain='signet') + mock.assert_called_once_with(conf_target=2, mode='economical', verbosity=2, block_policy_only=False, chain='signet') def test_fees_all_valid_modes_accepted(self): for mode in ('economical', 'conservative', 'unset'): diff --git a/backend/tests/test_rpc_service.py b/backend/tests/test_rpc_service.py index 428e779..19d4158 100644 --- a/backend/tests/test_rpc_service.py +++ b/backend/tests/test_rpc_service.py @@ -74,7 +74,7 @@ def mock_rpc(method, params): return None with patch.object(self.client, 'rpc_call', side_effect=mock_rpc): - result = self.client.estimate_smart_fee(2, "unset", 2) + result = self.client.estimate_smart_fee(2) self.assertAlmostEqual(result['feerate_sat_per_vb'], 0.0001 * 100_000) def test_feerate_conversion_is_correct(self): @@ -86,7 +86,7 @@ def mock_rpc(method, params): return None with patch.object(self.client, 'rpc_call', side_effect=mock_rpc): - result = self.client.estimate_smart_fee(2, "unset", 2) + result = self.client.estimate_smart_fee(2) self.assertAlmostEqual(result['feerate_sat_per_vb'], 100_000.0) def test_no_feerate_key_does_not_crash(self): @@ -98,7 +98,7 @@ def mock_rpc(method, params): return None with patch.object(self.client, 'rpc_call', side_effect=mock_rpc): - result = self.client.estimate_smart_fee(2, "unset", 2) + result = self.client.estimate_smart_fee(2) self.assertNotIn('feerate_sat_per_vb', result) def test_clamps_target_in_rpc_call(self): @@ -110,13 +110,13 @@ def mock_rpc(method, params): return None with patch.object(self.client, 'rpc_call', side_effect=mock_rpc) as mock: - self.client.estimate_smart_fee(1, "unset", 2) + self.client.estimate_smart_fee(1) esf_calls = [c for c in mock.call_args_list if c[0][0] == "estimatesmartfee"] self.assertGreater(len(esf_calls), 0) params = esf_calls[0][0][1] self.assertEqual(params[0], 2) - def test_verbosity_level_forwarded_to_rpc(self): + def test_verbosity_forwarded_to_rpc(self): def mock_rpc(method, params): if method == "estimatesmartfee": return {"feerate": 0.0001, "blocks": 2} @@ -125,9 +125,49 @@ def mock_rpc(method, params): return None with patch.object(self.client, 'rpc_call', side_effect=mock_rpc) as mock: - self.client.estimate_smart_fee(2, "unset", 3) + self.client.estimate_smart_fee(2, "unset", 1) esf_calls = [c for c in mock.call_args_list if c[0][0] == "estimatesmartfee"] - self.assertEqual(esf_calls[0][0][1], [2, "unset", 3]) + self.assertEqual(esf_calls[0][0][1], [2, "unset", 1, False]) + + def test_block_policy_only_forwarded_to_rpc(self): + def mock_rpc(method, params): + if method == "estimatesmartfee": + return {"feerate": 0.0001, "blocks": 2} + if method == "getblockchaininfo": + return {"chain": "main", "blocks": 800000} + return None + + with patch.object(self.client, 'rpc_call', side_effect=mock_rpc) as mock: + self.client.estimate_smart_fee(2, "unset", 2, block_policy_only=True) + esf_calls = [c for c in mock.call_args_list if c[0][0] == "estimatesmartfee"] + self.assertEqual(esf_calls[0][0][1], [2, "unset", 2, True]) + + def test_estimator_field_passed_through(self): + def mock_rpc(method, params): + if method == "estimatesmartfee": + return {"feerate": 0.0001, "blocks": 2, "estimator": "mempool"} + if method == "getblockchaininfo": + return {"chain": "main", "blocks": 800000} + return None + + with patch.object(self.client, 'rpc_call', side_effect=mock_rpc): + result = self.client.estimate_smart_fee(2, "unset", 2, block_policy_only=False) + self.assertEqual(result['estimator'], 'mempool') + + def test_mempool_health_uses_block_policy_only_false(self): + captured = {} + + def mock_rpc(method, params): + if method == "estimatesmartfee": + captured['params'] = params + return {"blocks": 2, "mempool_health_statistics": []} + if method == "getblockchaininfo": + return {"chain": "main", "blocks": 800000} + return None + + with patch.object(self.client, 'rpc_call', side_effect=mock_rpc): + self.client.get_mempool_health_statistics() + self.assertEqual(captured['params'][3], False) # --- get_single_block_stats cache safety -------------------------------- @@ -223,7 +263,7 @@ def mock_rpc(method, params): return None with patch.object(client, 'rpc_call', side_effect=mock_rpc): - result = client.estimate_smart_fee(2) + result = client.estimate_smart_fee(2, block_policy_only=False) self.assertEqual(result['chain'], 'testnet4') self.assertNotIn('chain_display', result) diff --git a/frontend/src/components/home/FeeCard.tsx b/frontend/src/components/home/FeeCard.tsx index fc89940..44c5d7c 100644 --- a/frontend/src/components/home/FeeCard.tsx +++ b/frontend/src/components/home/FeeCard.tsx @@ -29,6 +29,11 @@ export default function FeeCard({ label, target, data, loading, updating }: Prop

{target === 2 ? "1–2 blocks" : `within ${target} blocks`}

+ {data?.estimator && ( +

+ {data.estimator} +

+ )} )} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index c600b46..51097fc 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -21,7 +21,7 @@ describe('BitcoinCoreAPI', () => { }); const result = await api.getFeeEstimate(2, 'economical', 2, 'main'); - expect(fetchMock).toHaveBeenCalledWith('http://test-api:5001/fees/2/economical/2?chain=main', undefined); + expect(fetchMock).toHaveBeenCalledWith('http://test-api:5001/fees/2/economical/2?chain=main&block_policy_only=false', undefined); expect(result.feerate).toBe(0.0001); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e32a3aa..db2d00e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -38,8 +38,8 @@ export class BitcoinCoreAPI { return this.fetchJson("networks"); } - async getFeeEstimate(target: number = 2, mode: string = "economical", level: number = 2, chain: string): Promise { - return this.fetchJson(`fees/${target}/${mode}/${level}?chain=${chain}`); + async getFeeEstimate(target: number = 2, mode: string = "economical", level: number = 2, chain: string, blockPolicyOnly: boolean = false): Promise { + return this.fetchJson(`fees/${target}/${mode}/${level}?chain=${chain}&block_policy_only=${blockPolicyOnly}`); } async getBlockCount(chain?: string): Promise { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index eeac182..a7bb52e 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -31,6 +31,8 @@ export interface FeeEstimateResponse { errors?: string[]; /** Chain name ("main" | "test" | "testnet4" | "signet" | "regtest"). */ chain: string; + /** The estimator used (only present when block_policy_only=false). */ + estimator?: string; } export interface NetworkInfo {