Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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"
}
```
Expand Down
3 changes: 2 additions & 1 deletion backend/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 14 additions & 7 deletions backend/src/services/rpc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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", [])
Expand Down
5 changes: 3 additions & 2 deletions backend/src/services/rpc_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down
56 changes: 48 additions & 8 deletions backend/tests/test_rpc_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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}
Expand All @@ -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 --------------------------------

Expand Down Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/home/FeeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export default function FeeCard({ label, target, data, loading, updating }: Prop
<p className="text-[10px] text-[var(--text-secondary)] font-mono mt-2 tracking-wide">
{target === 2 ? "1–2 blocks" : `within ${target} blocks`}
</p>
{data?.estimator && (
<p className="text-[10px] text-orange-500/70 font-mono mt-1 tracking-widest uppercase">
{data.estimator}
</p>
)}
</div>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/services/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export class BitcoinCoreAPI {
return this.fetchJson<NetworkInfo[]>("networks");
}

async getFeeEstimate(target: number = 2, mode: string = "economical", level: number = 2, chain: string): Promise<FeeEstimateResponse> {
return this.fetchJson<FeeEstimateResponse>(`fees/${target}/${mode}/${level}?chain=${chain}`);
async getFeeEstimate(target: number = 2, mode: string = "economical", level: number = 2, chain: string, blockPolicyOnly: boolean = false): Promise<FeeEstimateResponse> {
return this.fetchJson<FeeEstimateResponse>(`fees/${target}/${mode}/${level}?chain=${chain}&block_policy_only=${blockPolicyOnly}`);
}

async getBlockCount(chain?: string): Promise<BlockchainInfo> {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading