Skip to content

Commit 668882d

Browse files
authored
Merge pull request #50 from gosuda/devin/1786437854-getblockchaininfo-fields
rpc: report real difficulty, time and mediantime from getblockchaininfo
2 parents 99d5d10 + 6b978aa commit 668882d

4 files changed

Lines changed: 281 additions & 24 deletions

File tree

CONCEPTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ Bitcoin Core's C++ consensus engine (`libbitcoinkernel`), compiled into `bitcoin
4949
### bitcoinconsensus
5050
Removed historical script verification backend. Previously linked as an extracted C library for non-taproot script checks before being deleted in favor of `bitcoinkernel`. The library lacked complete-prevout and Taproot script-path verification capabilities required for current mainnet script validation (exposed by block 938344 during mainnet IBD).
5151

52+
### Difficulty-1 target
53+
The network-independent reference target used by Bitcoin Core's difficulty
54+
calculation: compact nBits `0x1d00ffff`, rather than the selected network's
55+
PoW limit. Confusing the two makes every network report difficulty `1.0` at
56+
its easiest target. See
57+
`docs/solutions/logic-errors/core-float-parity-is-value-parity-not-json-text-parity.md`.
58+
59+
### Float value/text parity
60+
The distinction between equal IEEE-754 values and equal serialized spellings.
61+
Core's UniValue uses `%.16g`, while the live RPC path's sonic-rs serializer
62+
uses shortest-round-trip formatting, so compatibility means preserving the
63+
value and operation order, not forcing JSON text to match. See
64+
`docs/solutions/logic-errors/core-float-parity-is-value-parity-not-json-text-parity.md`.
65+
5266
### Rust interpreter (portable posture)
5367
The pure-Rust script verification path maintained alongside the bitcoinkernel default. Enabled under `--no-default-features` without C++ build dependencies. Its non-Taproot path is a stub that accepts only a bare `OP_TRUE` spend with an empty scriptSig and witness, so it cannot validate ordinary spends either, and it has no Taproot script-path support. What it does verify is the Taproot key path, in full. It is retained for differential testing and lightweight non-production environments; a mainnet sync stops early on the first real spend.
5468

crates/rpc/src/context.rs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -457,17 +457,29 @@ impl Context {
457457
.map_or_else(PruneStatus::default, |service| service.status())
458458
}
459459

460-
/// Returns the f64 difficulty for `bits`, computed against the network's
461-
/// `PoW` limit. Returns `0.0` on any conversion failure.
460+
/// Returns the f64 difficulty for `bits` using Bitcoin Core's calculation.
461+
///
462+
/// Keep the operation order here in sync with Core's `GetDifficulty`;
463+
/// changing the repeated 256 scaling into an equivalent exponentiation can
464+
/// change the final floating-point bit.
462465
#[must_use]
463466
pub fn difficulty_for_bits(&self, bits: bitcoin::CompactTarget) -> f64 {
464-
let params = bitcoin::params::Params::new(bitcoin_network(self.chain_network));
465-
let current_target = bitcoin::pow::Target::from_compact(bits);
466-
if current_target == bitcoin::pow::Target::ZERO {
467+
let consensus_bits = bits.to_consensus();
468+
let mantissa = consensus_bits & 0x00ff_ffff;
469+
if mantissa == 0 {
467470
return 0.0;
468471
}
469-
470-
target_to_f64(params.max_attainable_target) / target_to_f64(current_target)
472+
let mut shift = (consensus_bits >> 24) & 0xff;
473+
let mut difficulty = f64::from(0x0000_ffff_u32) / f64::from(mantissa);
474+
while shift < 29 {
475+
difficulty *= 256.0;
476+
shift += 1;
477+
}
478+
while shift > 29 {
479+
difficulty /= 256.0;
480+
shift -= 1;
481+
}
482+
difficulty
471483
}
472484

473485
/// Publishes a new best-chain tip and wakes getblocktemplate long polls.
@@ -746,13 +758,6 @@ fn bitcoin_network(network: Network) -> bitcoin::Network {
746758
}
747759
}
748760

749-
fn target_to_f64(target: bitcoin::pow::Target) -> f64 {
750-
target
751-
.to_be_bytes()
752-
.iter()
753-
.fold(0.0_f64, |acc, &byte| acc.mul_add(256.0, f64::from(byte)))
754-
}
755-
756761
#[cfg(test)]
757762
mod tests {
758763
use super::*;

crates/rpc/src/handlers/chain.rs

Lines changed: 159 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ use alloc::sync::Arc;
22
use bitcoin::consensus::encode::deserialize;
33
use bitcoin::hex::{DisplayHex as _, FromHex as _};
44
use core::str::FromStr as _;
5+
use core::{fmt, fmt::Write as _};
56

6-
use bitcoin_rs_chain::NodeStatus;
7+
use bitcoin_rs_chain::{NodeStatus, TipSnapshot};
78
use bitcoin_rs_primitives::Hash256;
89
use bitcoin_rs_pruning::policy::CORE_REORG_SAFETY_MARGIN;
910
use sonic_rs::{JsonContainerTrait as _, JsonValueTrait, Value, json};
@@ -14,13 +15,18 @@ use crate::handlers::{ensure_no_params, optional_bool, params_array, required_st
1415

1516
pub(crate) fn getblockchaininfo(ctx: &Arc<Context>, params: &Value) -> Result<Value, RpcError> {
1617
ensure_no_params(params)?;
17-
let applied = ctx.applied_height();
18+
let applied_tip = ctx.applied_tip.load_full();
19+
let applied = applied_tip.as_ref().map_or(0, |tip| tip.height);
1820
let headers = ctx.height();
19-
let difficulty = ctx.applied_tip.load_full().map_or(0.0, |tip| {
21+
let (difficulty, time, mediantime) = applied_tip.as_ref().map_or((0.0, 0_u64, 0_u64), |tip| {
2022
let tree = ctx.block_tree.read();
21-
tree.node(tip.tip_id)
22-
.ok()
23-
.map_or(0.0, |node| ctx.difficulty_for_bits(node.header.bits))
23+
tree.node(tip.tip_id).map_or((0.0, 0, 0), |node| {
24+
(
25+
ctx.difficulty_for_bits(node.header.bits),
26+
u64::from(node.header.time),
27+
u64::from(tree.median_time_past_at(tip.tip_id, 11).unwrap_or(0)),
28+
)
29+
})
2430
});
2531
let verification_progress = if headers > 0 {
2632
f64::from(applied) / f64::from(headers)
@@ -40,16 +46,21 @@ pub(crate) fn getblockchaininfo(ctx: &Arc<Context>, params: &Value) -> Result<Va
4046
fold_block_records(&blocks, applied, None)
4147
};
4248
let prune_status = ctx.prune_status();
43-
let bestblockhash = ctx.applied_hash().to_string_be();
44-
let chainwork = ctx.chainwork_hex();
49+
let bestblockhash = applied_tip
50+
.as_ref()
51+
.map_or_else(Hash256::default, |tip| tip.hash)
52+
.to_string_be();
53+
let chainwork = applied_tip
54+
.as_deref()
55+
.map_or_else(|| ctx.chainwork_hex(), chainwork_hex);
4556
let mut response = sonic_rs::Object::new();
4657
let _ = response.insert(&"chain", chain);
4758
let _ = response.insert(&"blocks", applied);
4859
let _ = response.insert(&"headers", headers);
4960
let _ = response.insert(&"bestblockhash", bestblockhash.as_str());
5061
let _ = response.insert(&"difficulty", json!(difficulty));
51-
let _ = response.insert(&"time", 0_u64);
52-
let _ = response.insert(&"mediantime", 0_u64);
62+
let _ = response.insert(&"time", time);
63+
let _ = response.insert(&"mediantime", mediantime);
5364
let _ = response.insert(&"verificationprogress", json!(verification_progress));
5465
let _ = response.insert(&"initialblockdownload", applied < headers);
5566
let _ = response.insert(&"chainwork", chainwork.as_str());
@@ -61,6 +72,15 @@ pub(crate) fn getblockchaininfo(ctx: &Arc<Context>, params: &Value) -> Result<Va
6172
let _ = response.insert(&"warnings", "");
6273
Ok(Value::from(response))
6374
}
75+
76+
fn chainwork_hex(tip: &TipSnapshot) -> String {
77+
let bytes: [u8; 32] = tip.chainwork.to_be_bytes();
78+
let mut out = String::with_capacity(bytes.len() * 2);
79+
for byte in bytes {
80+
let _: fmt::Result = write!(&mut out, "{byte:02x}");
81+
}
82+
out
83+
}
6484
pub(crate) fn getdifficulty(ctx: &Arc<Context>, params: &Value) -> Result<Value, RpcError> {
6585
ensure_no_params(params)?;
6686
let difficulty = {
@@ -941,10 +961,55 @@ mod tests {
941961
use core::sync::atomic::{AtomicUsize, Ordering};
942962

943963
use bitcoin::blockdata::constants::genesis_block;
964+
use bitcoin::hashes::Hash as _;
965+
use bitcoin::{BlockHash, CompactTarget, TxMerkleNode, block::Header, block::Version};
944966

945967
use super::*;
946968
use bitcoin_rs_chain::{ChainWork, NodeId, TipSnapshot};
947969

970+
fn context_with_tip(
971+
network: bitcoin_rs_primitives::Network,
972+
bits: u32,
973+
times: &[u32],
974+
) -> Arc<Context> {
975+
let mut context = Context::new();
976+
context.chain_network = network;
977+
let ctx = Arc::new(context);
978+
let (tip_id, tip_hash) = {
979+
let mut tree = ctx.block_tree.write();
980+
let mut parent = None;
981+
let mut previous_hash = BlockHash::all_zeros();
982+
let mut tip_id = NodeId::new(0);
983+
let mut tip_hash = Hash256::default();
984+
for (index, time) in times.iter().copied().enumerate() {
985+
let header = Header {
986+
version: Version::ONE,
987+
prev_blockhash: previous_hash,
988+
merkle_root: TxMerkleNode::all_zeros(),
989+
time,
990+
bits: CompactTarget::from_consensus(bits),
991+
nonce: u32::try_from(index).unwrap_or(u32::MAX),
992+
};
993+
previous_hash = header.block_hash();
994+
tip_id = tree
995+
.insert_node(parent, header, NodeStatus::Active)
996+
.unwrap_or(tip_id);
997+
tip_hash = Hash256::from_le_bytes(previous_hash.as_byte_array());
998+
parent = Some(tip_id);
999+
}
1000+
(tip_id, tip_hash)
1001+
};
1002+
let tip = TipSnapshot {
1003+
tip_id,
1004+
height: u32::try_from(times.len().saturating_sub(1)).unwrap_or(u32::MAX),
1005+
chainwork: ChainWork::ZERO,
1006+
hash: tip_hash,
1007+
};
1008+
ctx.set_chain_tip(tip.clone());
1009+
ctx.set_applied_tip(tip);
1010+
ctx
1011+
}
1012+
9481013
#[test]
9491014
fn subsidy_at_height_genesis_is_50_btc() {
9501015
assert_eq!(subsidy_at_height(0), 5_000_000_000);
@@ -1269,6 +1334,90 @@ mod tests {
12691334
);
12701335
}
12711336

1337+
#[test]
1338+
fn difficulty_matches_core_for_mainnet_and_regtest_targets() {
1339+
let ctx = Context::new();
1340+
let mainnet = ctx.difficulty_for_bits(CompactTarget::from_consensus(0x1d00_ffff));
1341+
assert_eq!(mainnet.to_bits(), 1.0_f64.to_bits());
1342+
let regtest = ctx.difficulty_for_bits(CompactTarget::from_consensus(0x207f_ffff));
1343+
let expected = 4.656_542_373_906_924_7e-10_f64;
1344+
assert_eq!(regtest.to_bits(), expected.to_bits());
1345+
}
1346+
1347+
#[test]
1348+
fn getblockchaininfo_reports_tip_time_and_median_time_past() {
1349+
let ctx = context_with_tip(
1350+
bitcoin_rs_primitives::Network::Regtest,
1351+
0x207f_ffff,
1352+
&[100, 300, 200],
1353+
);
1354+
let result = getblockchaininfo(&ctx, &json!([]))
1355+
.unwrap_or_else(|err| panic!("getblockchaininfo failed: {err}"));
1356+
1357+
assert_eq!(
1358+
result.get("time").and_then(JsonValueTrait::as_u64),
1359+
Some(200)
1360+
);
1361+
assert_eq!(
1362+
result.get("mediantime").and_then(JsonValueTrait::as_u64),
1363+
Some(200)
1364+
);
1365+
let difficulty = result
1366+
.get("difficulty")
1367+
.and_then(JsonValueTrait::as_f64)
1368+
.unwrap_or_default();
1369+
assert_eq!(
1370+
difficulty.to_bits(),
1371+
4.656_542_373_906_924_7e-10_f64.to_bits()
1372+
);
1373+
}
1374+
1375+
#[test]
1376+
fn getblockchaininfo_uses_one_applied_tip_snapshot() {
1377+
use bitcoin_rs_chain::ChainWork;
1378+
1379+
let ctx = context_with_tip(
1380+
bitcoin_rs_primitives::Network::Regtest,
1381+
0x207f_ffff,
1382+
&[100, 300, 200],
1383+
);
1384+
let Some(applied) = ctx.applied_tip.load_full() else {
1385+
panic!("applied tip missing");
1386+
};
1387+
let applied_chainwork = ChainWork::from_be_bytes([1; 32]);
1388+
ctx.set_applied_tip(TipSnapshot {
1389+
chainwork: applied_chainwork,
1390+
..(*applied).clone()
1391+
});
1392+
ctx.set_chain_tip(TipSnapshot {
1393+
tip_id: applied.tip_id,
1394+
height: 99,
1395+
chainwork: ChainWork::from_be_bytes([2; 32]),
1396+
hash: Hash256::from_le_bytes(&[9; 32]),
1397+
});
1398+
1399+
let result = getblockchaininfo(&ctx, &json!([]))
1400+
.unwrap_or_else(|err| panic!("getblockchaininfo failed: {err}"));
1401+
let expected_hash = applied.hash.to_string_be();
1402+
let expected_chainwork = "01".repeat(32);
1403+
assert_eq!(
1404+
result.get("blocks").and_then(JsonValueTrait::as_u64),
1405+
Some(2)
1406+
);
1407+
assert_eq!(
1408+
result.get("headers").and_then(JsonValueTrait::as_u64),
1409+
Some(99)
1410+
);
1411+
assert_eq!(
1412+
result.get("bestblockhash").and_then(JsonValueTrait::as_str),
1413+
Some(expected_hash.as_str())
1414+
);
1415+
assert_eq!(
1416+
result.get("chainwork").and_then(JsonValueTrait::as_str),
1417+
Some(expected_chainwork.as_str())
1418+
);
1419+
}
1420+
12721421
#[test]
12731422
fn getblockchaininfo_size_on_disk_uses_metadata_body_size() {
12741423
let genesis = genesis_block(bitcoin::Network::Regtest);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
title: Core float parity is value parity, not JSON text parity
3+
date: 2026-08-11
4+
category: docs/solutions/logic-errors
5+
module: crates/rpc/src/context.rs (Core-compatible numeric RPC fields)
6+
problem_type: logic_error
7+
component: rpc
8+
severity: medium
9+
applies_when:
10+
- "A numeric RPC field is compared against Bitcoin Core"
11+
- "A floating-point RPC value differs in its serialized spelling from Core"
12+
- "A compatibility fix is tempted to nudge an f64 to match another serializer"
13+
related_components:
14+
- bitcoin_core_rpc
15+
- floating_point_serialization
16+
tags:
17+
- floating-point
18+
- bitcoin-core
19+
- rpc-parity
20+
- serialization
21+
- difficulty
22+
---
23+
24+
# Core float parity is value parity, not JSON text parity
25+
26+
## Symptom
27+
28+
On a regtest chain, `getblockchaininfo.difficulty` reported `1.0` in
29+
bitcoin-rs while Bitcoin Core 27.0 reported `4.656542373906925e-10`.
30+
The same difficulty helper also affected `getblockheader`, `getblock`,
31+
`getdifficulty`, and `getmininginfo`.
32+
33+
## Cause
34+
35+
The original calculation divided the current target by the network's own
36+
proof-of-work limit. That makes the easiest target on every network report
37+
`1.0`, even though Core defines difficulty as a multiple of the
38+
difficulty-1 target and therefore uses that network-independent reference
39+
independently of the selected network.
40+
41+
There is a second compatibility trap after the value is corrected. Core's
42+
`GetDifficulty` performs this exact sequence:
43+
44+
1. Compute `0x0000ffff / (nBits & 0x00ffffff)` as `f64`.
45+
2. Repeatedly multiply by `256.0` while the nBits exponent is below 29.
46+
3. Repeatedly divide by `256.0` while the exponent is above 29.
47+
48+
The operation order matters for the final IEEE-754 bit. For
49+
`0x207fffff`, both implementations produce the double whose bits correspond
50+
to `4.6565423739069247e-10`.
51+
52+
Core's RPC layer then formats that double with `%.16g` through UniValue, while
53+
the production RPC path here serializes its `sonic_rs::Value` with
54+
`sonic_rs::to_string`. sonic-rs delegates finite f64 formatting to its
55+
shortest-round-trip `zmij` formatter. Consequently, Core prints
56+
`4.656542373906925e-10` and sonic-rs prints `4.6565423739069247e-10`; these
57+
are different strings even though the underlying value is the same.
58+
59+
## Fix
60+
61+
Compute difficulty with the Core mantissa ratio and the repeated `256.0`
62+
scaling loop. Guard a zero mantissa and return `0.0` for that impossible but
63+
representable header value rather than dividing by zero.
64+
65+
For direct algorithm tests, where both results are still pre-serialization
66+
doubles, assert compatibility at the value level with `f64::to_bits()`. Once
67+
either result has crossed the wire, Core's `%.16g` rendering can parse back to
68+
an adjacent double; compare with an appropriate tolerance or normalize the
69+
wire representation instead of requiring exact parsed-bit equality. Do not
70+
add a one-ULP adjustment to make sonic-rs's shortest spelling resemble Core's
71+
`%.16g` output: that changes the API value and makes it differ from Core's
72+
value precisely to match Core's formatting.
73+
74+
## Why This Works
75+
76+
The mantissa/exponent loop reproduces Core's `GetDifficulty` operation order,
77+
so the returned f64 is bit-for-bit equal to Core's result. The JSON spelling
78+
may still differ because the serializers choose different valid
79+
representations of the same IEEE-754 number.
80+
81+
## Prevention
82+
83+
- Compare direct cross-node floating-point algorithm results by exact value or
84+
`f64::to_bits()` before serialization. For values parsed from RPC text, use
85+
a documented tolerance or canonicalize the representation before comparing.
86+
- Preserve the reference implementation's floating-point operation order;
87+
algebraically equivalent target division or `powi` can change the last bit.
88+
- Treat a rendering mismatch as a serializer issue. Never change a numeric
89+
value merely to make its text match another implementation.

0 commit comments

Comments
 (0)