diff --git a/changelog.d/rpc-core-header-shape.md b/changelog.d/rpc-core-header-shape.md new file mode 100644 index 0000000000..87c3efb50c --- /dev/null +++ b/changelog.d/rpc-core-header-shape.md @@ -0,0 +1,12 @@ +Fixed + +- **Block header and block RPC match Bitcoin Core's JSON for inactive + headers, genesis, and difficulty text.** `getblockheader` and `getblock` + return a stored header that is no longer on the active chain + (`confirmations: -1`, its own `previousblockhash`, and a Class A body when + one exists). Genesis omits `previousblockhash`. `getblockheader` includes + `nextblockhash` and `target`. `getblockchaininfo` includes `bits` and + `target`. Difficulty uses 16 significant digits. `validateaddress` reports + `isscript` for P2TR and P2A and omits witness fields on pay-to-anchor. + `getmempoolinfo` includes `fullrbf`, `maxdatacarriersize` (null), and the + cluster limits. BTC amounts print as 8-decimal numbers. diff --git a/crates/rbitcoin-mempool/src/lib.rs b/crates/rbitcoin-mempool/src/lib.rs index 3eb76716c4..877e58e179 100644 --- a/crates/rbitcoin-mempool/src/lib.rs +++ b/crates/rbitcoin-mempool/src/lib.rs @@ -57,7 +57,7 @@ pub use fee_est::{ pub use fee_flow::FeeFlowMeter; pub use graph::{ frontier_feerate_from_chunks, weight_above_from_chunks, Chunk, Cluster, MempoolGraphStats, - SelectBudget, Selected, TxEntry, TxGraph, + SelectBudget, Selected, TxEntry, TxGraph, MAX_CLUSTER_COUNT, MAX_CLUSTER_VSIZE, }; pub use orphanage::{OrphanSnapshot, Orphanage}; pub use packed::VinAux; diff --git a/crates/rbitcoin-net/src/tx_relay.rs b/crates/rbitcoin-net/src/tx_relay.rs index 3c1a479a5a..fe3d08e316 100644 --- a/crates/rbitcoin-net/src/tx_relay.rs +++ b/crates/rbitcoin-net/src/tx_relay.rs @@ -3071,6 +3071,23 @@ impl MempoolHub { Some((stats, a_mod, d_mod, chunk_fee, chunk_w)) } + /// Core `-limitclustercount` and `-limitclustersize` (count, vbytes). + pub fn cluster_limits(&self) -> (u32, u64) { + let g = self.lock_read(); + ( + g.graph.cluster_count_limit() as u32, + g.graph.cluster_vsize_limit(), + ) + } + + /// Defaults when no hub is attached (`MAX_CLUSTER_COUNT`, `MAX_CLUSTER_VSIZE`). + pub fn default_cluster_limits() -> (u32, u64) { + ( + rbitcoin_mempool::MAX_CLUSTER_COUNT as u32, + rbitcoin_mempool::MAX_CLUSTER_VSIZE, + ) + } + /// Cluster count/size overlay (`None` = keep default). pub fn set_cluster_limits(&self, count: Option, size_kvb: Option) { self.lock_write().set_cluster_limits(count, size_kvb); diff --git a/crates/rbitcoin-node/src/health/metrics.rs b/crates/rbitcoin-node/src/health/metrics.rs index b84d6394de..4051376d46 100644 --- a/crates/rbitcoin-node/src/health/metrics.rs +++ b/crates/rbitcoin-node/src/health/metrics.rs @@ -233,9 +233,7 @@ fn chain_gauges(out: &mut Exposition, chain: &ChainHub, sh_index: bool) { "Seconds since the tip block time.", age, ); - let difficulty = rec.map_or(0.0, |(_, rec)| { - rbitcoin_consensus::difficulty_from_bits(rec.bits) - }); + let difficulty = rec.map_or(0.0, |(_, rec)| rbitcoin_rpc::difficulty_rpc_f64(rec.bits)); out.gauge( "rbitcoin_difficulty", "Tip difficulty (getblockchaininfo.difficulty).", diff --git a/crates/rbitcoin-query/src/chain_view.rs b/crates/rbitcoin-query/src/chain_view.rs index 353edc16e1..8e6071fa45 100644 --- a/crates/rbitcoin-query/src/chain_view.rs +++ b/crates/rbitcoin-query/src/chain_view.rs @@ -193,6 +193,22 @@ impl Query { Ok(g.map.get(hash).copied().map(Height)) } + /// Active-chain height of a hash already loaded from the header archive. + /// + /// Skips the extra header read in [`Self::height_of_hash`]. An ancestry + /// walk then costs one `prev_fk` read per step plus this index. + pub fn confirmed_height_of_hash(&self, hash: &[u8; 32]) -> Result, QueryError> { + let Some(tip) = self.tip_height() else { + return Ok(None); + }; + self.ensure_height_by_hash_index(tip)?; + let g = self + .height_by_hash + .lock() + .unwrap_or_else(|e| e.into_inner()); + Ok(g.map.get(hash).copied().map(Height)) + } + /// Ensure height index matches `tip`. /// /// Incremental for any `new_tip ≠ prev_tip`. Full walk only when the map diff --git a/crates/rbitcoin-rpc/Cargo.toml b/crates/rbitcoin-rpc/Cargo.toml index d2489af118..ea9774714e 100644 --- a/crates/rbitcoin-rpc/Cargo.toml +++ b/crates/rbitcoin-rpc/Cargo.toml @@ -21,7 +21,7 @@ tower = { workspace = true } tower-http = { workspace = true } tokio = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["arbitrary_precision"] } getrandom = "0.4" base64 = "0.23" diff --git a/crates/rbitcoin-rpc/src/lib.rs b/crates/rbitcoin-rpc/src/lib.rs index 10bcc8388d..49c4b0e296 100644 --- a/crates/rbitcoin-rpc/src/lib.rs +++ b/crates/rbitcoin-rpc/src/lib.rs @@ -9,7 +9,8 @@ mod server; pub use auth::{default_socket_path, default_token_path, read_cookie_file, RpcAuth}; pub use methods::{ - gbt_template, submit_received_block, RpcActive, RpcContext, RpcRegtest, SubmitBlockOutcome, + difficulty_rpc_f64, gbt_template, submit_received_block, RpcActive, RpcContext, RpcRegtest, + SubmitBlockOutcome, }; pub use server::{run_rpc, RpcConfig, RpcHandle, DEFAULT_RPC_WORK_QUEUE, RPC_MAX_HTTP_BODY}; diff --git a/crates/rbitcoin-rpc/src/methods/chain.rs b/crates/rbitcoin-rpc/src/methods/chain.rs index 448274a600..07d6aba894 100644 --- a/crates/rbitcoin-rpc/src/methods/chain.rs +++ b/crates/rbitcoin-rpc/src/methods/chain.rs @@ -2,7 +2,7 @@ use super::*; use bitcoin::consensus::{deserialize, Encodable}; use bitcoin::hashes::Hash; use bitcoin::{Amount, Block, BlockHash, ScriptBuf, Txid}; -use rbitcoin_primitives::Height; +use rbitcoin_primitives::{median_time_past_times, Height}; use serde_json::{json, Value}; use std::sync::atomic::Ordering; use std::time::Instant; @@ -88,7 +88,7 @@ pub(crate) fn getblockchaininfo(ctx: &RpcContext) -> Result { "blocks": tip, "headers": headers, "bestblockhash": best, - "difficulty": difficulty_at_tip(ctx).unwrap_or(0.0), + "difficulty": core_f64_json(difficulty_at_tip(ctx).unwrap_or(0.0)), "time": time, "mediantime": mediantime, "verificationprogress": verificationprogress, @@ -101,6 +101,12 @@ pub(crate) fn getblockchaininfo(ctx: &RpcContext) -> Result { if let Some(h) = ctx.query.pruneheight() { info["pruneheight"] = json!(h.0); } + if let Some(h) = ctx.query.tip_height() { + if let Ok(Some((_, rec))) = ctx.query.header_at_height(h) { + info["bits"] = json!(format!("{:08x}", rec.bits)); + info["target"] = json!(target_hex(rec.bits)); + } + } Ok(info) } @@ -135,6 +141,64 @@ pub(crate) fn rpc_warnings(ctx: &RpcContext) -> Vec { pub(crate) use rbitcoin_consensus::difficulty_from_bits; +/// Compact-target bytes as Core `target` (64 hex chars). +pub(crate) fn target_hex(bits: u32) -> String { + let target = bitcoin::Target::from_compact(bitcoin::CompactTarget::from_consensus(bits)); + format!("{target:064x}") +} + +/// Core `UniValue` `setprecision(16)` defaultfloat. Keeps the 16th significant +/// digit (`132757073449487.52` prints `132757073449487.5`). +pub(crate) fn format_core_double(v: f64) -> String { + if !v.is_finite() || v == 0.0 { + return "0".to_string(); + } + let neg = v.is_sign_negative(); + let v = v.abs(); + let mut exp = v.log10().floor() as i32; + let mut scaled = (v / 10f64.powi(exp - 15)).round(); + if scaled >= 1e16 { + scaled /= 10.0; + exp += 1; + } + let digits = format!("{scaled:.0}"); + let pos = exp + 1; + let body = if pos <= 0 { + format!( + "0.{}{}", + "0".repeat((-pos) as usize), + digits.trim_end_matches('0') + ) + } else if (pos as usize) >= digits.len() { + format!("{}{}", digits, "0".repeat(pos as usize - digits.len())) + } else { + let (a, b) = digits.split_at(pos as usize); + let b = b.trim_end_matches('0'); + if b.is_empty() { + a.to_string() + } else { + format!("{a}.{b}") + } + }; + if neg { + format!("-{body}") + } else { + body + } +} + +pub(crate) fn core_f64_json(v: f64) -> Value { + let s = format_core_double(v); + Value::Number(s.parse().expect("core double decimal")) +} + +/// `getblockchaininfo.difficulty`: [`difficulty_from_bits`] at 16 significant digits. +pub fn difficulty_rpc_f64(bits: u32) -> f64 { + core_f64_json(difficulty_from_bits(bits)) + .as_f64() + .expect("core difficulty decimal") +} + pub(crate) fn difficulty_at_tip(ctx: &RpcContext) -> Result { let tip = ctx .query @@ -149,7 +213,7 @@ pub(crate) fn difficulty_at_tip(ctx: &RpcContext) -> Result { } pub(crate) fn getdifficulty(ctx: &RpcContext) -> Result { - Ok(json!(difficulty_at_tip(ctx)?)) + Ok(core_f64_json(difficulty_at_tip(ctx)?)) } pub(crate) fn getblockheader(ctx: &RpcContext, params: &RpcParams) -> Result { @@ -157,11 +221,24 @@ pub(crate) fn getblockheader(ctx: &RpcContext, params: &RpcParams) -> Result Result { let (_, rec) = ctx .query .header_at_height(height) @@ -172,10 +249,7 @@ pub(crate) fn getblockheader(ctx: &RpcContext, params: &RpcParams) -> Result 0 { ctx.query @@ -187,7 +261,7 @@ pub(crate) fn getblockheader(ctx: &RpcContext, params: &RpcParams) -> Result Result Result { + let mut raw = Vec::new(); + hdr.consensus_encode(&mut raw) + .map_err(|_| rpc_error(ERR_MISC, "header encode"))?; + Ok(hex_encode(raw)) +} + +fn insert_previousblockhash(obj: &mut Value, prev: &str) { + if prev.is_empty() { + return; + } + if let Some(o) = obj.as_object_mut() { + o.insert("previousblockhash".into(), json!(prev)); + } +} + +struct OffChainHeader { + rec: rbitcoin_store::HeaderRecord, + header_fk: rbitcoin_primitives::Fk, + height: u32, + prev_hash: Option<[u8; 32]>, + chainwork: String, + /// Median of this header and up to 10 ancestors (Core `GetMedianTimePast`). + mediantime: u32, +} + +/// Header-sync ancestry cap (`stored_header_height`). One `prev_fk` read per +/// step; active membership is the height index. A longer stale branch errors +/// instead of scanning the rest of the branch. +const OFF_CHAIN_WALK_MAX: u32 = 10_000; + +/// Header that is stored but not on the active chain. Height and chainwork +/// walk `prev_fk` until an active ancestor (or genesis). +fn off_chain_header(ctx: &RpcContext, hash: &[u8; 32]) -> Result, Value> { + let Some((header_fk, rec)) = ctx + .query + .get_header_by_hash(hash) + .map_err(|e| rpc_error(ERR_MISC, e.to_string()))? + else { + return Ok(None); + }; + let mut cur = rec.clone(); + let mut works = Vec::new(); + let mut times = Vec::with_capacity(11); + let mut immediate_prev: Option<[u8; 32]> = None; + for step in 0..OFF_CHAIN_WALK_MAX { + works.push(work_from_bits(cur.bits)); + if times.len() < 11 { + times.push(cur.timestamp); + } + if cur.prev_fk.is_null() { + return Ok(Some(off_chain_done( + rec, + header_fk, + (works.len() as u32).saturating_sub(1), + None, + hex_encode(sum_header_work(works).to_be_bytes()), + ×, + ))); + } + let parent = ctx + .query + .get_header(cur.prev_fk) + .map_err(|e| rpc_error(ERR_MISC, e.to_string()))?; + if step == 0 { + immediate_prev = Some(parent.hash); + } + if let Some(h) = ctx + .query + .confirmed_height_of_hash(&parent.hash) + .map_err(|e| rpc_error(ERR_MISC, e.to_string()))? + { + if times.len() < 11 { + times.push(parent.timestamp); + } + fill_mtp_below(ctx, &mut times, h.0)?; + let height = h.0.saturating_add(works.len() as u32); + let extra = sum_header_work(works); + let cw = match ctx.chain.as_ref() { + Some(hub) => hub + .work_through_height(h.0) + .map(|w| w + extra) + .unwrap_or(extra), + None => extra, + }; + return Ok(Some(off_chain_done( + rec, + header_fk, + height, + immediate_prev, + hex_encode(cw.to_be_bytes()), + ×, + ))); + } + cur = parent; + } + Err(rpc_error(ERR_MISC, "side header walk exceeded")) +} + +fn off_chain_done( + rec: rbitcoin_store::HeaderRecord, + header_fk: rbitcoin_primitives::Fk, + height: u32, + prev_hash: Option<[u8; 32]>, + chainwork: String, + times: &[u32], +) -> OffChainHeader { + OffChainHeader { + rec, + header_fk, + height, + prev_hash, + chainwork, + mediantime: median_time_past_times(times), + } +} + +/// Ancestors strictly below an active parent, until the 11-timestamp window is full. +fn fill_mtp_below(ctx: &RpcContext, times: &mut Vec, parent_height: u32) -> Result<(), Value> { + let mut h = parent_height; + while times.len() < 11 && h > 0 { + h -= 1; + let Some((_, rec)) = ctx + .query + .header_at_height(Height(h)) + .map_err(|e| rpc_error(ERR_MISC, e.to_string()))? + else { + break; + }; + times.push(rec.timestamp); + } + Ok(()) +} + +/// MTP when the block is not a stored header row: at most 10 parent reads. +fn median_time_from_wire(ctx: &RpcContext, time: u32, prev_hash: [u8; 32]) -> Result { + let mut times = vec![time]; + if prev_hash != [0u8; 32] { + if let Some((_, rec)) = ctx + .query + .get_header_by_hash(&prev_hash) + .map_err(|e| rpc_error(ERR_MISC, e.to_string()))? + { + times.push(rec.timestamp); + let mut fk = rec.prev_fk; + while times.len() < 11 && !fk.is_null() { + let parent = ctx + .query + .get_header(fk) + .map_err(|e| rpc_error(ERR_MISC, e.to_string()))?; + times.push(parent.timestamp); + fk = parent.prev_fk; + } + } + } + Ok(median_time_past_times(×)) +} + +fn work_from_bits(bits: u32) -> bitcoin::Work { + bitcoin::block::Header { + version: bitcoin::block::Version::from_consensus(0), + prev_blockhash: BlockHash::from_byte_array([0u8; 32]), + merkle_root: bitcoin::TxMerkleNode::from_byte_array([0u8; 32]), + time: 0, + bits: bitcoin::CompactTarget::from_consensus(bits), + nonce: 0, + } + .work() +} + +fn sum_header_work(works: Vec) -> bitcoin::Work { + rbitcoin_net::sum_work(works.into_iter()).unwrap_or(bitcoin::Work::from_be_bytes([0xff; 32])) +} + +fn wire_header_from_record( + rec: &rbitcoin_store::HeaderRecord, + prev: [u8; 32], +) -> bitcoin::block::Header { + bitcoin::block::Header { + version: bitcoin::block::Version::from_consensus(rec.version), + prev_blockhash: BlockHash::from_byte_array(prev), + merkle_root: bitcoin::TxMerkleNode::from_byte_array(rec.merkle_root), + time: rec.timestamp, + bits: bitcoin::CompactTarget::from_consensus(rec.bits), + nonce: rec.nonce, + } +} + +fn off_chain_header_value( + ctx: &RpcContext, + loc: &OffChainHeader, + verbose: bool, +) -> Result { + let prev = loc.prev_hash.unwrap_or([0u8; 32]); + if !verbose { + return Ok(json!(header_hex(&wire_header_from_record(&loc.rec, prev))?)); + } + let n_tx = ctx + .query + .header_tx_fks(loc.header_fk, Some(&loc.rec.hash)) + .ok() + .flatten() + .map(|v| v.len()) + .unwrap_or(0); + let mut obj = json!({ + "hash": hash_hex_display(&loc.rec.hash), + "confirmations": -1, + "height": loc.height, + "version": loc.rec.version, + "versionHex": format!("{:08x}", loc.rec.version), + "merkleroot": hash_hex_display(&loc.rec.merkle_root), + "time": loc.rec.timestamp, + "mediantime": loc.mediantime, + "nonce": loc.rec.nonce, + "bits": format!("{:08x}", loc.rec.bits), + "difficulty": core_f64_json(difficulty_from_bits(loc.rec.bits)), + "chainwork": loc.chainwork, + "nTx": n_tx, + "target": target_hex(loc.rec.bits), + }); + if let Some(p) = loc.prev_hash { + insert_previousblockhash(&mut obj, &hash_hex_display(&p)); + } + Ok(obj) +} + +fn held_header_value(ctx: &RpcContext, block: &Block, verbose: bool) -> Result { + if !verbose { + return Ok(json!(header_hex(&block.header)?)); + } + let bits = block.header.bits.to_consensus(); + let version = block.header.version.to_consensus(); + let prev = block.header.prev_blockhash.to_byte_array(); + let mediantime = median_time_from_wire(ctx, block.header.time, prev)?; + let mut obj = json!({ + "hash": hash_hex_display(&block.block_hash().to_byte_array()), + "confirmations": -1, + "version": version, + "versionHex": format!("{:08x}", version), + "merkleroot": hash_hex_display(&block.header.merkle_root.to_byte_array()), + "time": block.header.time, + "mediantime": mediantime, + "nonce": block.header.nonce, + "bits": format!("{:08x}", bits), + "difficulty": core_f64_json(difficulty_from_bits(bits)), + "chainwork": hex_encode(block.header.work().to_be_bytes()), + "nTx": block.txdata.len(), + "target": target_hex(bits), + }); + if prev != [0u8; 32] { + insert_previousblockhash(&mut obj, &hash_hex_display(&prev)); + } + Ok(obj) } /// 32-byte BE chainwork hex (regtest = 2 per block). Empty store → 64 zeros. @@ -253,7 +587,11 @@ fn enrich_block_header_json( return; }; o.insert("versionHex".into(), json!(version_hex(version))); - o.insert("difficulty".into(), json!(difficulty_from_bits(bits))); + o.insert( + "difficulty".into(), + core_f64_json(difficulty_from_bits(bits)), + ); + o.insert("target".into(), json!(target_hex(bits))); match height { Some(h) => { o.insert("chainwork".into(), json!(chainwork_hex(ctx, Some(h)))); @@ -365,10 +703,10 @@ pub(crate) fn getblock(ctx: &RpcContext, params: &RpcParams) -> Result Result Result Result { + if verbosity == 0 { + let mut raw = Vec::new(); + block + .consensus_encode(&mut raw) + .map_err(|_| rpc_error(ERR_MISC, "block encode"))?; + return Ok(json!(hex_encode(raw))); + } + let txids: Vec = block + .txdata + .iter() + .map(|tx| hash_hex_display(&tx.compute_txid().to_byte_array())) + .collect(); + let bits = block.header.bits.to_consensus(); + let version = block.header.version.to_consensus(); + let prev = block.header.prev_blockhash.to_byte_array(); + let loc = off_chain_header(ctx, hash)?; + let chainwork = loc + .as_ref() + .map(|l| l.chainwork.clone()) + .unwrap_or_else(|| held_chainwork_hex(ctx, block)); + let mediantime = match &loc { + Some(loc) => loc.mediantime, + None => median_time_from_wire(ctx, block.header.time, prev)?, + }; + let mut obj = json!({ + "hash": hash_hex_display(hash), + "confirmations": -1, + "version": version, + "merkleroot": hash_hex_display(&block.header.merkle_root.to_byte_array()), + "time": block.header.time, + "mediantime": mediantime, + "nonce": block.header.nonce, + "bits": format!("{:08x}", bits), + "nTx": block.txdata.len(), + "tx": txids, + }); + if let Some(loc) = loc { + obj["height"] = json!(loc.height); + } + if prev != [0u8; 32] { + insert_previousblockhash(&mut obj, &hash_hex_display(&prev)); + } + enrich_block_header_json(&mut obj, ctx, version, bits, None, Some(chainwork)); + if verbosity >= 2 { + insert_block_size_fields(&mut obj, block); + obj["tx"] = json!(getblock_v2_tx_json(ctx, block)); + } + Ok(obj) +} + fn getblock_unknown_hash(ctx: &RpcContext, hash: [u8; 32], verbosity: u32) -> Result { + if let Ok(Some(block)) = ctx.query.reconstruct_archived_block(&hash) { + return disconnected_block_value(ctx, &hash, &block, verbosity); + } let typed = BlockHash::from_byte_array(hash); if let Some(block) = ctx.chain.as_ref().and_then(|c| c.held_body(&typed)) { - if verbosity == 0 { - let mut raw = Vec::new(); - block - .consensus_encode(&mut raw) - .map_err(|_| rpc_error(ERR_MISC, "block encode"))?; - return Ok(json!(hex_encode(raw))); - } - let txids: Vec = block - .txdata - .iter() - .map(|tx| hash_hex_display(&tx.compute_txid().to_byte_array())) - .collect(); - let bits = block.header.bits.to_consensus(); - let version = block.header.version.to_consensus(); - let mut obj = json!({ - "hash": hash_hex_display(&hash), - "confirmations": -1, - "version": version, - "merkleroot": hash_hex_display(&block.header.merkle_root.to_byte_array()), - "time": block.header.time, - "nonce": block.header.nonce, - "bits": format!("{:08x}", bits), - "nTx": block.txdata.len(), - "tx": txids, - }); - enrich_block_header_json( - &mut obj, - ctx, - version, - bits, - None, - Some(held_chainwork_hex(ctx, &block)), - ); - if verbosity >= 2 { - insert_block_size_fields(&mut obj, &block); - obj["tx"] = json!(getblock_v2_tx_json(ctx, &block)); - } - return Ok(obj); + return disconnected_block_value(ctx, &hash, &block, verbosity); } let header_only = ctx.chain.as_ref().is_some_and(|c| c.knows_header(&typed)) || ctx.query.get_header_by_hash(&hash).ok().flatten().is_some(); @@ -569,7 +929,7 @@ pub(crate) fn scantxoutset(ctx: &RpcContext, params: &RpcParams) -> Result Result Result (false, false), Some(AddressType::P2sh) => (true, false), - Some(AddressType::P2wpkh | AddressType::P2tr | AddressType::P2a) => (false, true), - Some(AddressType::P2wsh) => (true, true), + Some(AddressType::P2wpkh) => (false, true), + Some(AddressType::P2wsh | AddressType::P2tr | AddressType::P2a) => (true, true), Some(_) | None => (false, addr.witness_program().is_some()), }; let mut obj = json!({ @@ -53,13 +53,16 @@ pub(crate) fn validateaddress(ctx: &RpcContext, params: &RpcParams) -> Result (u32, u64) { + ctx.mempool + .as_ref() + .map(|mp| mp.cluster_limits()) + .unwrap_or_else(MempoolHub::default_cluster_limits) +} + +fn relay_fee_json() -> Value { + sat_btc_json(rbitcoin_consensus::policy::MIN_RELAY_FEE_RATE_SAT_PER_KVB as i64) +} + pub(crate) fn getmempoolinfo(ctx: &RpcContext) -> Result { + let (limit_count, limit_vsize) = mempool_info_limits(ctx); let Some(mp) = ctx.mempool.as_ref() else { return Ok(json!({ "loaded": true, "size": 0, "bytes": 0, "usage": 0, - "total_fee": 0.0, + "total_fee": sat_btc_json(0), "maxmempool": 0, - "mempoolminfee": MempoolHub::relay_fee_btc_per_kb(), - "minrelaytxfee": MempoolHub::relay_fee_btc_per_kb(), - "incrementalrelayfee": MempoolHub::relay_fee_btc_per_kb(), + "mempoolminfee": relay_fee_json(), + "minrelaytxfee": relay_fee_json(), + "incrementalrelayfee": relay_fee_json(), "unbroadcastcount": 0, + "fullrbf": true, + "maxdatacarriersize": Value::Null, + "limitclustercount": limit_count, + "limitclustersize": limit_vsize, "permitbaremultisig": true, "optimal": true, "orphanage": { "size": 0, "bytes": 0 }, @@ -31,13 +47,17 @@ pub(crate) fn getmempoolinfo(ctx: &RpcContext) -> Result { "size": size, "bytes": bytes, "usage": bytes, - "total_fee": (total_fee as f64) / 100_000_000.0, + "total_fee": sat_btc_json(total_fee as i64), "maxmempool": mp.max_weight(), "mempoolminfee": sat_btc_json(mp.mempool_min_fee_sat_kvb() as i64), - "minrelaytxfee": MempoolHub::relay_fee_btc_per_kb(), - "incrementalrelayfee": MempoolHub::relay_fee_btc_per_kb(), + "minrelaytxfee": relay_fee_json(), + "incrementalrelayfee": relay_fee_json(), "relay_enabled": mp.relay_enabled(), "unbroadcastcount": mp.unbroadcast_count(), + "fullrbf": true, + "maxdatacarriersize": Value::Null, + "limitclustercount": limit_count, + "limitclustersize": limit_vsize, "permitbaremultisig": true, "optimal": true, "orphanage": { diff --git a/crates/rbitcoin-rpc/src/methods/mod.rs b/crates/rbitcoin-rpc/src/methods/mod.rs index f95c5f37bf..06ea326a46 100644 --- a/crates/rbitcoin-rpc/src/methods/mod.rs +++ b/crates/rbitcoin-rpc/src/methods/mod.rs @@ -13,6 +13,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Instant; +pub use chain::difficulty_rpc_f64; pub(crate) use chain::{tip_hash_height, wait_timeout_ms}; pub(crate) use mine::gbt_longpoll_id; pub use mine::{gbt_template, submit_received_block}; diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 118a6813c3..a83dac2f3f 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -282,6 +282,22 @@ fn getmempoolinfo_permitbaremultisig_is_always_true() { mem["permitbaremultisig"], true, "Libre has no Core IsStandard bare-multisig gate" ); + assert_eq!(mem["fullrbf"], true); + assert!(mem["maxdatacarriersize"].is_null()); + assert_eq!(mem["limitclustercount"], 64); + assert_eq!(mem["limitclustersize"], 101_000); + assert_eq!( + serde_json::to_string(&mem["mempoolminfee"]).unwrap(), + "0.00000100" + ); + assert_eq!( + serde_json::to_string(&mem["minrelaytxfee"]).unwrap(), + "0.00000100" + ); + assert_eq!( + serde_json::to_string(&mem["total_fee"]).unwrap(), + "0.00000000" + ); let _ = std::fs::remove_dir_all(&dir); } @@ -1318,12 +1334,73 @@ fn getblock_named_verbose_genesis_and_hex() { let genesis = dispatch(&ctx, "getblockhash", vec![json!(0)]).unwrap(); let named = named(json!({"blockhash": genesis.clone(), "verbose": true})); let v = dispatch(&ctx, "getblock", named).unwrap(); - assert_eq!(v["previousblockhash"], ""); + assert!( + v.get("previousblockhash").is_none(), + "genesis omits previousblockhash: {v}" + ); assert_eq!(v["tx"].as_array().unwrap().len(), 1); - let hex = dispatch(&ctx, "getblock", vec![genesis, json!(0)]).unwrap(); + let hex = dispatch(&ctx, "getblock", vec![genesis.clone(), json!(0)]).unwrap(); assert!(hex.as_str().unwrap().len() > 160); + let genesis_hdr = dispatch(&ctx, "getblockheader", vec![genesis.clone()]).unwrap(); + assert!( + genesis_hdr.get("previousblockhash").is_none(), + "{genesis_hdr}" + ); + assert_eq!(genesis_hdr["target"].as_str().unwrap().len(), 64); + let info = dispatch(&ctx, "getblockchaininfo", vec![]).unwrap(); + assert_eq!(info["bits"], genesis_hdr["bits"]); + assert_eq!(info["target"], genesis_hdr["target"]); + let diff = serde_json::to_string(&dispatch(&ctx, "getdifficulty", vec![]).unwrap()).unwrap(); + assert!(!diff.contains('e') && !diff.contains('E'), "{diff}"); + dispatch( + &ctx, + "generatetoaddress", + vec![json!(1), json!(p2wpkh_regtest().0)], + ) + .unwrap(); + let parent = dispatch(&ctx, "getblockheader", vec![genesis]).unwrap(); + assert!(parent["nextblockhash"].as_str().is_some(), "{parent}"); + let tip = dispatch(&ctx, "getbestblockhash", vec![]).unwrap(); + let tip_hdr = dispatch(&ctx, "getblockheader", vec![tip.clone()]).unwrap(); + assert!(tip_hdr.get("nextblockhash").is_none(), "{tip_hdr}"); + let parent_hash = tip_hdr["previousblockhash"].clone(); + dispatch(&ctx, "invalidateblock", vec![tip.clone()]).unwrap(); + let gone = dispatch(&ctx, "getblockheader", vec![tip.clone()]).unwrap(); + assert_eq!(gone["confirmations"], json!(-1), "{gone}"); + assert_eq!(gone["previousblockhash"], parent_hash); + let body = dispatch(&ctx, "getblock", vec![tip, json!(1)]).unwrap(); + assert_eq!(body["confirmations"], json!(-1), "{body}"); + assert!(!body["tx"].as_array().unwrap().is_empty(), "{body}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Core `mediantime` on an inactive header is the median of that header and +/// up to 10 ancestors, not the header's own timestamp. +#[test] +fn inactive_header_mediantime_is_branch_median() { + let (ctx, dir, _hub) = ctx_regtest_hub(); + let addr = p2wpkh_regtest().0; + let mut hashes = Vec::new(); + for i in 0..12u32 { + let t = 1_700_000_000u64 + u64::from(i) * 1_000; + dispatch(&ctx, "setmocktime", vec![json!(t)]).unwrap(); + let mined = dispatch(&ctx, "generatetoaddress", vec![json!(1), json!(addr)]).unwrap(); + hashes.push(mined.as_array().unwrap()[0].clone()); + } + let old_tip = hashes[11].clone(); + dispatch(&ctx, "invalidateblock", vec![hashes[9].clone()]).unwrap(); + let gone = dispatch(&ctx, "getblockheader", vec![old_tip.clone()]).unwrap(); + assert_eq!(gone["confirmations"], json!(-1), "{gone}"); + assert_eq!(gone["height"], json!(12), "{gone}"); + assert_eq!(gone["previousblockhash"], hashes[10], "{gone}"); + // Heights 2..=12. Sorted median is height 7: 1_700_000_000 + 6*1000. + assert_eq!(gone["mediantime"], json!(1_700_006_000u64), "{gone}"); + let body = dispatch(&ctx, "getblock", vec![old_tip, json!(1)]).unwrap(); + assert_eq!(body["confirmations"], json!(-1), "{body}"); + assert_eq!(body["mediantime"], gone["mediantime"], "{body}"); let _ = std::fs::remove_dir_all(&dir); } + #[test] fn miniwallet_raw_scan_and_gettxout() { let (ctx, dir, _hub) = ctx_regtest_hub(); @@ -3810,6 +3887,26 @@ fn decode_rpc_subset() { .unwrap(); assert_eq!(mainnet, json!({"isvalid": false})); + let p2tr_spk = ScriptBuf::from_bytes( + rbitcoin_primitives::hex_decode( + "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + ); + let p2tr = Address::from_script(&p2tr_spk, BtcNetwork::Regtest).expect("p2tr address"); + let tr = dispatch(&ctx, "validateaddress", vec![json!(p2tr.to_string())]).unwrap(); + assert_eq!(tr["isscript"], json!(true), "{tr}"); + assert_eq!(tr["iswitness"], json!(true)); + assert_eq!(tr["witness_version"], json!(1)); + + let p2a_spk = ScriptBuf::from_bytes(vec![0x51, 0x02, 0x4e, 0x73]); + let p2a = Address::from_script(&p2a_spk, BtcNetwork::Regtest).expect("p2a address"); + let anchor = dispatch(&ctx, "validateaddress", vec![json!(p2a.to_string())]).unwrap(); + assert_eq!(anchor["isscript"], json!(true), "{anchor}"); + assert_eq!(anchor["iswitness"], json!(true)); + assert!(anchor.get("witness_version").is_none(), "{anchor}"); + assert!(anchor.get("witness_program").is_none(), "{anchor}"); + let still_never = dispatch(&ctx, "createrawtransaction", vec![]).unwrap_err(); assert_eq!(still_never["code"], ERR_METHOD_NOT_FOUND); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs index c2c8e68153..5c4941d0b9 100644 --- a/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs +++ b/crates/rbitcoin-rpc/src/regtest_chain_ops_journey.rs @@ -770,7 +770,10 @@ fn chain_ops_sigop_adjusted_entry_and_min_fee(ctx: &RpcContext, cbs: &mut TrueCo (info["size"].clone(), info["bytes"].clone()), (json!(1), json!(4_000)) ); - assert_eq!(info["total_fee"], json!(0.00002)); + assert_eq!( + serde_json::to_string(&info["total_fee"]).unwrap(), + "0.00002000" + ); // Newest accept: the journey already admitted earlier spends into this ring. let recent = ctx.mempool.as_ref().unwrap().recent_accepts(); assert_eq!( diff --git a/docs/rpc.md b/docs/rpc.md index dc18d69689..a241d4ccba 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -126,22 +126,22 @@ still wait for durable SH when shindex is on. |--------|-------| | `help` / `getrpcinfo` / `uptime` / `stop` | Control | | `echo` | Testing RPC. Returns arguments as a positional array. AuthServiceProxy `{args: [...], argN: ...}` is peeled only here. Mixed `submitpackage`/`sendrawtransaction`/`testmempoolaccept` `{args, maxfeerate}` is expanded in the Core-functional proxy, not on the node. | -| `getblockchaininfo` / `getblockcount` / `getbestblockhash` / `getblockhash` | Chain tip. `getblockcount` / `getbestblockhash` wait for the in-flight tip-accept job (not the rest of a catch-up burst). `headers` is the best known header height (`submitheader` / P2P headers may lead `blocks`). `chainwork` is summed header work (regtest 2 per block). `size_on_disk` is a walk of `{datadir}/store` file lengths (plus `--datadir-cold` seqsigwit when split). `verificationprogress` is `blocks / headers` clamped to `[0, 1]` (`1.0` when `headers` is 0). `initialblockdownload` is the Core RPC name for **relay-inhibited**: `--min-chain-work` and `--max-tip-age` after densify/`enter_tip_mode`, not “still catching up”. `--prune-seqsigwit` sets `pruned: true` and `pruneheight` (omitted when off). | -| `getblockheader` / `getblock` (verbosity 0/1/2) | Archive reconstruct. `getblockheader` includes `chainwork`. Verbosity 0/2 below `pruneheight` is `-8` `Block not available (pruned data)`; verbosity 1 (txids) stays. | +| `getblockchaininfo` / `getblockcount` / `getbestblockhash` / `getblockhash` | Chain tip. `getblockcount` / `getbestblockhash` wait for the in-flight tip-accept job (not the rest of a catch-up burst). `headers` is the best known header height (`submitheader` / P2P headers may lead `blocks`). `chainwork` is summed header work (regtest 2 per block). `bits` and `target` are the tip compact target. `difficulty` prints with 16 significant digits. `size_on_disk` is a walk of `{datadir}/store` file lengths (plus `--datadir-cold` seqsigwit when split). `verificationprogress` is `blocks / headers` clamped to `[0, 1]` (`1.0` when `headers` is 0). `initialblockdownload` is the Core RPC name for **relay-inhibited**: `--min-chain-work` and `--max-tip-age` after densify/`enter_tip_mode`, not “still catching up”. `--prune-seqsigwit` sets `pruned: true` and `pruneheight` (omitted when off). | +| `getblockheader` / `getblock` (verbosity 0/1/2) | Archive reconstruct. `getblockheader` includes `chainwork`, `target`, and `nextblockhash` when the next active block exists. Genesis omits `previousblockhash`. A header that is stored but not on the active chain (reorg or `invalidateblock`) returns `confirmations: -1` and its own `previousblockhash`. `getblock` returns a Class A body for that hash the same way, or a held never-confirmed body. Verbosity 0/2 below `pruneheight` is `-8` `Block not available (pruned data)`; verbosity 1 (txids) stays. | | `getblockstats` | All networks. Stamped `txstat` fee/size/weight/`n_in`. Size and count fields match Core for the keys we return: coinbase is left out of `ins`, fees, `total_size`, `total_weight`, and the segwit totals. `utxo_increase` is every output minus those inputs. `utxo_increase_actual` drops outputs that do not enter the UTXO set (height 0, the two mainnet BIP30-repeat coinbases, unspendable scripts). Omits coins-DB `utxo_size_inc` / `utxo_size_inc_actual` (selecting those names is invalid statistic `-8`). Unstamped leftover reconstructs then lazy-stamps. Reconstruct miss is `block body not in store`. Dummy `blk00000.dat` is shim-only so `rpc_getblockstats.py`'s rename-file needle stays Core-phrased. Pruned unstamped height is `-8` Core pruned text. | -| `getdifficulty` | From tip bits | +| `getdifficulty` | From tip bits, 16 significant digits | | `getnetworkinfo` / `getconnectioncount` / `getpeerinfo` | BIP324 v2-only; `getpeerinfo` is the live session table. `timeoffset` is VERSION clock minus connect time (`0` before handshake). `synced_headers` is the height of that peer's advertised best block when we know it, else `-1`. `synced_blocks` is that height when the hash is on our best chain, else `-1`. `getnetworkinfo.timeoffset` is the median of outbound handshake-complete offsets (`0` if none). `mapped_as` is present when `--asmap` / `{datadir}/ip_asn.dat` mapped the peer (Core field; omitted without a map or ASN 0). `version` is the fixed integer `190000` (Core 0.19 client version; see [getnetworkinfo.version](#getnetworkinfoversion)). `localservices` matches advertised `NETWORK\|WITNESS\|P2P_V2`, or `NETWORK_LIMITED\|WITNESS\|P2P_V2` under `--prune-seqsigwit`. `localaddresses` lists `--external-ip` (`score` = Core `LOCAL_MANUAL`) | | `getnettotals` | All networks. Raw TCP `totalbytesrecv` / `totalbytessent` on live sessions. `uploadtarget` is a Core-shaped stub (`target` 0). | | `ping` | All networks. Queues a ping on each live session (`null`). | | `addpeeraddress` | Hidden Core name. Inserts `{address,port}` into addrman RAM (does not rewrite `peers` per call). | | `getnodeaddresses` | Sample from addrman (`count=0` → all). Optional `network` filter. | | `addnode` / `disconnectnode` / `addconnection` | All networks. Hostnames resolve at dial (network default P2P port if omitted). `addnode add` and `--connect` retry until a live session. `addnode onetry` dials once and errors if DNS fails. `disconnectnode` by `nodeid` or address | -| `getmempoolinfo` / `getrawmempool` / `getmempoolentry` | MempoolHub. `maxmempool` is the operator weight budget (`--mempool-size-mb`). `ancestorcount` / `descendantcount` (and size/fee sums) walk the cluster graph. `vsize`, `ancestorsize`, `descendantsize` and `chunkweight` are sigop-adjusted (Core `GetAdjustedWeight`, `--bytes-per-sigop`); `weight` is raw. Verbose `fees.{base,modified,ancestor,descendant,chunk}` and `chunkweight` include `prioritisetransaction` deltas; top-level `ancestorfees` / `descendantfees` stay base satoshis. `unbroadcastcount` / `unbroadcast` track `sendrawtransaction` txs until a peer getdata's them. `orphanage.{size,bytes}` is the parked missing-parent side pool (vsize). `permitbaremultisig` is always `true` (Libre has no Core `IsStandard` bare-multisig gate; `--permitbaremultisig` is not a node flag). | +| `getmempoolinfo` / `getrawmempool` / `getmempoolentry` | MempoolHub. `maxmempool` is the operator weight budget (`--mempool-size-mb`). `fullrbf` is `true` (Libre always replaces). `maxdatacarriersize` is JSON null (no datacarrier cap). `limitclustercount` / `limitclustersize` are the live cluster caps (count and vbytes). Fee amounts are 8-decimal BTC. `ancestorcount` / `descendantcount` (and size/fee sums) walk the cluster graph. `vsize`, `ancestorsize`, `descendantsize` and `chunkweight` are sigop-adjusted (Core `GetAdjustedWeight`, `--bytes-per-sigop`); `weight` is raw. Verbose `fees.{base,modified,ancestor,descendant,chunk}` and `chunkweight` include `prioritisetransaction` deltas; top-level `ancestorfees` / `descendantfees` stay base satoshis. `unbroadcastcount` / `unbroadcast` track `sendrawtransaction` txs until a peer getdata's them. `orphanage.{size,bytes}` is the parked missing-parent side pool (vsize). `permitbaremultisig` is always `true` (Libre has no Core `IsStandard` bare-multisig gate; `--permitbaremultisig` is not a node flag). | | `getorphantxs` | Hidden operator dump. Verbosity 0 txids, 1 details + `from` peer ids, 2 + hex. Not listed by `help` / `getrpcinfo`. Counts-only `getmempoolinfo.orphanage` is not a substitute. | | `getrawtransaction` | Class A + mempool. Optional Core `blockhash` arg is accepted and ignored. Verbose objects share `tx_to_json` with `decoderawtransaction` / `getblock` verbosity 2 (`scriptSig`, `scriptPubKey.type`). A confirmed verbose result also carries Core's `confirmations`, `blockhash`, `blocktime`, and `time`. Below `pruneheight`: `-8` `Transaction not available (pruned data)` (not `-5`). | | `decoderawtransaction` | All networks. Decode hex. Optional `iswitness`: `false` refuses a BIP141 marker (`-22 TX decode failed`). Extra trailing bytes also `-22`. `scriptSig.asm` is rust-bitcoin, not Core `ScriptToAsmStr` sighash suffixes. Coinbase vin is Core-shaped `{coinbase, sequence}` (plus rbitcoin's `n`), with no `txid`/`vout`. | | `decodescript` | All networks. `asm`, Core-style `type`, `hex`, and `address` when `Address::from_script` succeeds. No `p2sh` wrap, `segwit` wrap, or `desc` / miniscript. | -| `validateaddress` | All networks. Valid: `isvalid`, `address`, `scriptPubKey`, `isscript`, `iswitness`, plus `witness_version` / `witness_program` when segwit. Invalid (parse fail or wrong chain): `{isvalid: false}` only — no `error` / `error_locations`. | +| `validateaddress` | All networks. Valid: `isvalid`, `address`, `scriptPubKey`, `isscript`, `iswitness`, plus `witness_version` / `witness_program` when segwit except pay-to-anchor. P2TR and P2A set `isscript` true. Invalid (parse fail or wrong chain): `{isvalid: false}` only — no `error` / `error_locations`. | | `sendrawtransaction` / `testmempoolaccept` | `sendrawtransaction` is live accept (RPC still admits under `-blocksonly`; serving-only refuse is IBD / tip-not-ready `relay disabled`). `testmempoolaccept` / `submitpackage` `vsize` is sigop-adjusted. `testmempoolaccept` is dry-run (`MempoolHub::test_accept`: prepare + scripts + RBF/cluster checks, no commit / announce / RBF eviction / orphan park). Invalid hex / decode is `-22 TX decode failed` (same as `decoderawtransaction`). Confirmed on the active chain is `txn-already-known` (tip + confirmed-strong); live mempool duplicate is `txn-already-in-mempool`; archive-only after invalidate is not already-known. RPC-submit only: `maxfeerate` is **sat/vB** (default **10000**; `0` unlimited; `>= 100000` is `-8`). sendraw over-cap is `-25` configured-max text; `testmempoolaccept` reject-reason stays `max-fee-exceeded`. `maxburnamount` default **0** (valued unspendable / OP_RETURN outs). nVersion outside 1/2 is `"version"`. P2P `accept_tx` does not apply these caps. Multi-tx `testmempoolaccept` is sequential: earlier `allowed: true` rows stay (no abort-class blanking). Core functional tests still speak BTC/kvB via `scripts/core-functional/rpc_proxy.py`. | | `estimatesmartfee` | **10-minute inclusion frontier** — not Core historical multi-horizon. Core's result shape: `{feerate, blocks}`, or `{errors, blocks}` with no `feerate` when there is no estimate. Like Core, `feerate` is at least `mempoolminfee`. See [`mempool-fee-estimation.md`](./mempool-fee-estimation.md). | | `estimaterawfee` | Same 10-minute frontier product as `estimatesmartfee` (Core RPC name for harness scripts). Not Core historical `estimaterawfee` buckets. |