Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion crates/index/src/block_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ impl BlockRecord {
/// The serialized block header, when the record carries one.
///
/// A record read straight out of the log never does. One resolved through
/// `Context::record_for_hash` does, because that fills it from the block
/// `ChainHandles::block_by_hash` does, because that fills it from the block
/// tree.
#[must_use]
pub fn header_bytes(&self) -> Option<&[u8; bitcoin_rs_primitives::Header::LEN]> {
Expand Down
37 changes: 15 additions & 22 deletions crates/rpc/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ impl ChainHandles {
/// it. For a tree-resolved `(height, hash)` pair, the log may contribute
/// matching durable body metadata.
#[must_use]
pub(crate) fn record_for_hash(&self, hash: Hash256) -> Option<BlockRecord> {
pub fn block_by_hash(&self, hash: Hash256) -> Option<BlockRecord> {
// Tree authority resolves identity first; the exact `(height, hash)`
// record may then enrich its payload fields.
if let Some(mut record) = self.header_record(hash) {
Expand Down Expand Up @@ -1025,13 +1025,7 @@ impl ChainHandles {
/// records remain available.
#[must_use]
pub(crate) fn block_hash_at_height(&self, height: u32) -> Option<Hash256> {
if let Some(tip) = self.applied_tip.load_full() {
return self.hash_at_height_from_tip(&tip, height);
}
if height == 0 {
return Some(self.chain_network.genesis_block_hash());
}
record_at_height(&self.blocks.read(), height).map(|candidate| Hash256::from(candidate.hash))
self.block_hash_at_height_in_view(&self.applied_view(), height)
}

/// Returns the applied-chain hash at `height` within a retained view.
Expand All @@ -1057,12 +1051,6 @@ impl ChainHandles {
record_at_height(&self.blocks.read(), height).map(|candidate| Hash256::from(candidate.hash))
}

/// Returns a known block by hash.
#[must_use]
pub fn block_by_hash(&self, hash: Hash256) -> Option<BlockRecord> {
self.record_for_hash(hash)
}

/// Returns the applied block at a height.
///
/// Once an applied tip exists, its ancestry is authoritative. The session
Expand All @@ -1087,7 +1075,7 @@ impl ChainHandles {
) -> Option<BlockRecord> {
if let Some(tip) = view.tip() {
let hash = self.hash_at_height_from_tip(tip, height)?;
return self.record_for_hash(hash);
return self.block_by_hash(hash);
}
record_at_height(&self.blocks.read(), height).cloned()
}
Expand Down Expand Up @@ -1480,6 +1468,12 @@ mod tests {
expected,
"block_by_height disagrees with the log at height {height}"
);
let expected_hash = if height == 0 {
Some(ctx.chain.chain_network.genesis_block_hash())
} else {
expected.map(Hash256::from)
};
assert_eq!(ctx.chain.block_hash_at_height(height), expected_hash);
}
}

Expand Down Expand Up @@ -1828,7 +1822,7 @@ mod tests {
/// used to hold; only where it is produced changed.
///
/// Read through a resolved record now, because that is where a header comes
/// from: the tree, via `record_for_hash`. A record built straight from a
/// from: the tree, via `block_by_hash`. A record built straight from a
/// block has none.
#[test]
fn header_hex_is_unchanged_by_sourcing_the_header_from_the_tree() {
Expand All @@ -1839,7 +1833,7 @@ mod tests {
let _ = tree.insert_node(None, block.header, bitcoin_rs_chain::NodeStatus::Active);
}
let hash = Hash256::from(block.block_hash());
let Some(record) = ctx.chain.record_for_hash(hash) else {
let Some(record) = ctx.chain.block_by_hash(hash) else {
panic!("the tree knows this hash");
};

Expand All @@ -1855,12 +1849,12 @@ mod tests {

/// The tree's header must reach a caller even when the log has the block.
///
/// `record_for_hash` returns the cached record for its payload facts — size,
/// `block_by_hash` returns the cached record for its payload facts — size,
/// transaction count — and that record has no header. Returning
/// it unchanged would answer with none, which is what an earlier revision of
/// this change did until this test caught it.
#[test]
fn record_for_hash_answers_with_the_tree_header_for_a_cached_record() {
fn block_by_hash_answers_with_the_tree_header_for_a_cached_record() {
let block = Network::Regtest.genesis_block();
let ctx = Arc::new(Context::new());
{
Expand All @@ -1874,7 +1868,7 @@ mod tests {
assert!(cached.header_bytes().is_none(), "the log stores no header");
ctx.chain.add_block(cached);

let Some(record) = ctx.chain.record_for_hash(hash) else {
let Some(record) = ctx.chain.block_by_hash(hash) else {
panic!("the tree knows this hash");
};
assert_eq!(
Expand Down Expand Up @@ -1903,7 +1897,6 @@ mod tests {
ctx.chain
.add_block(BlockRecord::synthetic(3, BlockHash::from(hash)));

assert!(ctx.chain.record_for_hash(hash).is_none());
assert!(ctx.chain.block_by_hash(hash).is_none());
}

Expand Down Expand Up @@ -1951,7 +1944,7 @@ mod tests {
// tree.
let record = ctx
.chain
.record_for_hash(hash)
.block_by_hash(hash)
.expect("tree resolves the hash");

assert_eq!(
Expand Down
10 changes: 1 addition & 9 deletions crates/rpc/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use bitcoin_rs_script::{
use sonic_rs::{JsonValueMutTrait as _, JsonValueTrait as _, Value};

use crate::error::RpcError;
#[cfg(test)]
use crate::tx_render;

/// Maps a native network onto the rust-bitcoin network for the sanctioned
Expand Down Expand Up @@ -299,15 +300,6 @@ fn omit_json_nulls(value: &mut Value) {
}
}

/// Projects one output script into the versioned `scriptPubKey` object,
/// reusing the transport renderer and validating against the pinned type.
pub(crate) fn script_pub_key_typed(
script: &[u8],
network: Network,
) -> Result<corepc_types::ScriptPubKey, RpcError> {
sonic_rs::from_value(&tx_render::script_pub_key_json(script, network)).map_err(RpcError::from)
}

/// The coinbase transaction object carried by verbose block responses.
///
/// Takes the block's `txs.first()` directly. Returns `None` when the block
Expand Down
2 changes: 1 addition & 1 deletion crates/rpc/src/handlers/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2782,7 +2782,7 @@ mod tests {
ctx.chain.add_block(BlockRecord::from_block(1, &block));
let record = ctx
.chain
.record_for_hash(Hash256::from(a1_hash))
.block_by_hash(Hash256::from(a1_hash))
.expect("fixture record resolves through the tree");
ctx.chain.applied_tip.store(Some(Arc::clone(&a_tip)));

Expand Down
82 changes: 70 additions & 12 deletions crates/rpc/src/handlers/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ pub(crate) fn gettxout(ctx: &Arc<Context>, params: &Value) -> Result<Value, RpcE
&& let Ok(vout) = usize::try_from(vout_u32)
&& let Some(output) = tx.outputs.get(vout)
{
return txout_typed(ctx, output, 0, false, best_block);
return Ok(txout_json(ctx, output, 0, false, best_block));
}
}

Expand All @@ -208,31 +208,31 @@ pub(crate) fn gettxout(ctx: &Arc<Context>, params: &Value) -> Result<Value, RpcE
return Ok(Value::new_null());
};
let confirmations = view.height().saturating_sub(live.height).saturating_add(1);
txout_typed(
Ok(txout_json(
ctx,
&live.txout,
confirmations,
live.coinbase,
view.hash(ctx.chain.chain_network),
)
))
}

fn txout_typed(
fn txout_json(
ctx: &Context,
output: &TxOut,
confirmations: u32,
coinbase: bool,
best_block: Hash256,
) -> Result<Value, RpcError> {
typed_to_sonic_omitting_nulls(&v31::GetTxOut {
best_block: best_block.to_string(),
confirmations,
value: sat_to_btc(output.value.to_sat()),
script_pubkey: convert::script_pub_key_typed(
) -> Value {
json!({
"bestblock": best_block.to_string(),
"confirmations": confirmations,
"value": sat_to_btc(output.value.to_sat()),
"scriptPubKey": crate::tx_render::script_pub_key_json(
&output.script_pubkey,
ctx.chain.chain_network,
)?,
coinbase,
),
"coinbase": coinbase,
})
}

Expand Down Expand Up @@ -2170,6 +2170,64 @@ mod tests {
mod gettxout_via_utxo_tests {
use super::*;

#[test]
fn gettxout_preserves_script_schema_and_omits_unset_fields()
-> Result<(), Box<dyn std::error::Error>> {
use bitcoin_rs_utxo::contract::{BlockChanges, UtxoAdd, commit_block_changes};

let ctx = Arc::new(Context::new());
let handler = crate::Handler::new(Arc::clone(&ctx));
let txid = Txid(Hash256::from_le_bytes(&[0x42; 32]));
let best_block = ctx.chain.applied_hash();
let mut scripts: Vec<_> = convert::fixtures::by_shape()
.into_iter()
.map(|(_, script)| script)
.collect();
// A truncated push must retain Core's malformed-instruction ASM too.
scripts.push(vec![0x02, 0x01]);
for (index, script) in scripts.into_iter().enumerate() {
let vout = u32::try_from(index)?;
let (satoshis, btc) = [
(0, 0.0_f64),
(1, 0.000_000_01),
(123_456_789, 1.234_567_89),
(2_100_000_000_000_000, 21_000_000.0),
][index % 4];
let script_hex = script.to_lower_hex_string();
let coinbase = index % 2 == 0;
let mut changes = BlockChanges::default();
changes.add(UtxoAdd::new(
OutPoint::new(txid, vout),
TxOut {
value: Amount::from_sat(satoshis),
script_pubkey: Script::from_bytes(script),
},
coinbase,
0,
));
commit_block_changes(&ctx.chain.utxo.fixture_set(), &changes, &best_block)?;
let value = handler.dispatch("gettxout", &json!([txid.to_string(), vout, false]))?;
let text = sonic_rs::to_string(&value)?;
// The pinned DTO remains an independent wire-schema oracle, without
// deserializing and reserializing scripts on the production path.
let typed: v31::GetTxOut = serde_json::from_str(&text)?;
assert_eq!(typed.best_block, best_block.to_string());
assert_eq!(typed.confirmations, 1);
assert_eq!(typed.value.to_bits(), btc.to_bits());
assert_eq!(typed.coinbase, coinbase);
assert_eq!(typed.script_pubkey.hex, script_hex);
if script_hex == "0201" {
assert_eq!(typed.script_pubkey.asm, "[error]");
}
assert!(typed.script_pubkey.descriptor.is_some());
let script = &value["scriptPubKey"];
assert!(script.get("reqSigs").is_none());
assert!(script.get("addresses").is_none());
assert!(!text.contains(":null"));
}
Ok(())
}

#[test]
fn gettxout_returns_null_for_unknown_outpoint() {
let ctx = Arc::new(Context::new());
Expand Down
6 changes: 3 additions & 3 deletions crates/rpc/src/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ fn route_block(ctx: &Arc<Context>, suffix: &str, with_details: bool) -> Response
let Some(format) = format else {
return format_not_found(available_formats());
};
let Some(record) = ctx.chain.record_for_hash(hash) else {
let Some(record) = ctx.chain.block_by_hash(hash) else {
return not_found_with(format!("{hash_text} not found"));
};
let Some(_render) = ctx.try_acquire_block_render() else {
Expand Down Expand Up @@ -247,7 +247,7 @@ fn route_block_part(ctx: &Arc<Context>, suffix: &str) -> Response {
let Some(format) = format else {
return format_not_found(available_formats());
};
let Some(record) = ctx.chain.record_for_hash(hash) else {
let Some(record) = ctx.chain.block_by_hash(hash) else {
return not_found_with(format!("{hash_text} not found"));
};
let Some(_render) = ctx.try_acquire_block_render() else {
Expand Down Expand Up @@ -987,7 +987,7 @@ fn header_chain_context(
) -> BlockChainContext {
let real = ctx
.chain
.record_for_hash(record.hash)
.block_by_hash(record.hash)
.unwrap_or_else(|| BlockRecord {
hash: BlockHash::from(record.hash),
height: record.height,
Expand Down
Loading