From c0853d3f5bbdc5f0000070f1d50dda4003e31fd5 Mon Sep 17 00:00:00 2001 From: "rearden-grok[bot]" <317016512+rearden-grok[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:17:38 -0700 Subject: [PATCH] test: observe Esplora script classes on the broadcast journey GET /tx scriptpubkey_type, genesis previousblockhash, and the empty fee-estimate response now sit on esplora_broadcast_visible_in_rpc_and_electrum. The units those beats fail when inverted are deleted. --- crates/rbitcoin-esplora/src/handlers.rs | 54 +------ crates/rbitcoin-esplora/src/script_fields.rs | 137 ---------------- crates/rbitcoin-test/tests/cross_surface.rs | 157 ++++++++++++++++++- 3 files changed, 154 insertions(+), 194 deletions(-) diff --git a/crates/rbitcoin-esplora/src/handlers.rs b/crates/rbitcoin-esplora/src/handlers.rs index f09ff3f3b8..77051dd9a2 100644 --- a/crates/rbitcoin-esplora/src/handlers.rs +++ b/crates/rbitcoin-esplora/src/handlers.rs @@ -2001,8 +2001,7 @@ pub async fn post_tx_package(State(st): State, body: Bytes) -> Respons #[cfg(test)] mod pure_helper_tests { - use super::{block_summary_json, fee_estimates_from, outspend_json, resolve_address_sh}; - use bitcoin::Network; + use super::{block_summary_json, fee_estimates_from, outspend_json}; use rbitcoin_primitives::{Fk, Height}; use rbitcoin_query::testutil::FixtureChain; use rbitcoin_query::{Query, TxApply}; @@ -2065,16 +2064,6 @@ mod pure_helper_tests { assert_eq!(est["144"], json!(0.101)); } - #[test] - fn fee_routes_do_not_invent_an_estimate() { - // A target the estimator cannot answer is left out, as electrs does. - let est = fee_estimates_from(&[(1, -1.0), (6, 0.000_002)]).expect("one target"); - assert!(est.get("1").is_none(), "{est}"); - assert_eq!(est["6"], json!(0.2)); - assert!(fee_estimates_from(&[(1, -1.0)]).is_none()); - assert!(fee_estimates_from(&[]).is_none()); - } - #[test] fn block_summary_bits_u32_and_witness_size_weight() { let (dir, q) = temp_query(); @@ -2141,45 +2130,4 @@ mod pure_helper_tests { assert_eq!(v["vin"], 0, "outspend vin from spent slot: {v}"); let _ = std::fs::remove_dir_all(dir); } - - #[test] - fn resolve_address_sh_and_block_summary_surface() { - // Invalid / wrong-network addresses. - assert!(resolve_address_sh("not-an-address", Network::Bitcoin).is_err()); - assert!(resolve_address_sh( - "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - Network::Regtest - ) - .is_err()); - // Well-known mainnet P2WPKH. - let sh = resolve_address_sh( - "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - Network::Bitcoin, - ) - .expect("mainnet p2wpkh"); - assert_ne!(sh, [0u8; 32]); - - let (dir, q) = temp_query(); - let hash = seed_genesis(&q); - let summary = block_summary_json(&q, &hash).expect("genesis summary"); - assert_eq!(summary["height"], 0); - assert_eq!(summary["tx_count"], 1); - assert!(summary["previousblockhash"].is_null()); - assert!(summary["id"].as_str().unwrap().len() == 64); - assert!(summary["difficulty"].as_f64().unwrap() > 0.0); - assert!(summary["size"].as_u64().unwrap() > 80); - assert!(summary["weight"].as_u64().unwrap() > 0); - // Unknown hash → NotFound class error. - assert!(block_summary_json(&q, &[0x11; 32]).is_err()); - let _ = q.sample_reset_reconstruct_archived(); - let _ = block_summary_json(&q, &hash).expect("summary again"); - assert_eq!( - q.sample_reset_reconstruct_archived(), - 0, - "block JSON uses stamped size/weight" - ); - assert!(q.reconstruct_archived_block(&hash).unwrap().is_some()); - assert!(q.sample_reset_reconstruct_archived() >= 1); - let _ = std::fs::remove_dir_all(dir); - } } diff --git a/crates/rbitcoin-esplora/src/script_fields.rs b/crates/rbitcoin-esplora/src/script_fields.rs index f2860b05e9..8cfc23c4ae 100644 --- a/crates/rbitcoin-esplora/src/script_fields.rs +++ b/crates/rbitcoin-esplora/src/script_fields.rs @@ -63,140 +63,3 @@ fn is_bare_multisig(s: &Script) -> bool { } matches!(b[b.len() - 1], 0xae | 0xaf) } - -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::hashes::Hash; - use bitcoin::key::PublicKey; - use bitcoin::opcodes::all::*; - use bitcoin::script::Builder; - use bitcoin::{PubkeyHash, ScriptHash, WPubkeyHash, XOnlyPublicKey}; - - /// secp256k1 G compressed / x-only (well-known generator). - const G_COMPRESSED: [u8; 33] = [ - 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, - 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, - 0xf8, 0x17, 0x98, - ]; - const G_XONLY: [u8; 32] = [ - 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, 0x0b, - 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, - 0x17, 0x98, - ]; - - #[test] - fn p2pkh_fields() { - let h160 = PubkeyHash::from_slice(&[0x11; 20]).unwrap(); - let spk = Builder::new() - .push_opcode(OP_DUP) - .push_opcode(OP_HASH160) - .push_slice(h160) - .push_opcode(OP_EQUALVERIFY) - .push_opcode(OP_CHECKSIG) - .into_script(); - let f = esplora_script_fields(spk.as_bytes(), Network::Bitcoin); - assert_eq!(f.script_type, "p2pkh"); - assert!(f.asm.contains("OP_DUP")); - assert!(f.asm.contains("OP_HASH160")); - assert!(!f.hex.is_empty()); - assert!(f.address.is_some()); - } - - #[test] - fn p2wpkh_fields() { - let w = WPubkeyHash::from_slice(&[0x22; 20]).unwrap(); - let spk = Builder::new().push_int(0).push_slice(w).into_script(); - let f = esplora_script_fields(spk.as_bytes(), Network::Bitcoin); - assert_eq!(f.script_type, "v0_p2wpkh"); - assert!(f.address.as_ref().unwrap().starts_with("bc1q")); - } - - #[test] - fn p2tr_fields() { - let xonly = XOnlyPublicKey::from_slice(&G_XONLY).unwrap(); - let spk = Builder::new() - .push_int(1) - .push_x_only_key(&xonly) - .into_script(); - let f = esplora_script_fields(spk.as_bytes(), Network::Bitcoin); - assert_eq!(f.script_type, "v1_p2tr"); - assert!(f.address.as_ref().unwrap().starts_with("bc1p")); - } - - #[test] - fn p2sh_p2wpkh_outer_is_p2sh() { - // Outer P2SH wrapping a v0 P2WPKH redeem script (type is still p2sh). - let w = WPubkeyHash::from_slice(&[0x33; 20]).unwrap(); - let redeem = Builder::new().push_int(0).push_slice(w).into_script(); - let sh = ScriptHash::hash(redeem.as_bytes()); - let spk = Builder::new() - .push_opcode(OP_HASH160) - .push_slice(sh) - .push_opcode(OP_EQUAL) - .into_script(); - let f = esplora_script_fields(spk.as_bytes(), Network::Bitcoin); - assert_eq!(f.script_type, "p2sh"); - assert!(f.address.as_ref().unwrap().starts_with('3')); - } - - #[test] - fn unknown_op_true() { - let f = esplora_script_fields(&[0x51], Network::Regtest); - assert_eq!(f.script_type, "unknown"); - // rust-bitcoin names OP_1 as OP_PUSHNUM_1 in asm. - assert!(f.asm.contains("1") || f.asm.contains("OP_")); - assert!(f.address.is_none()); - } - - #[test] - fn p2pk_type() { - let pk = PublicKey::from_slice(&G_COMPRESSED).expect("G"); - let spk = Builder::new() - .push_key(&pk) - .push_opcode(OP_CHECKSIG) - .into_script(); - let f = esplora_script_fields(spk.as_bytes(), Network::Bitcoin); - assert_eq!(f.script_type, "p2pk"); - assert!(f.asm.contains("OP_CHECKSIG")); - } - - #[test] - fn p2wsh_op_return_and_bare_multisig_types() { - // v0 P2WSH: OP_0 + 32-byte push - let spk_wsh = { - let mut v = vec![0x00, 0x20]; - v.extend_from_slice(&[0xab; 32]); - v - }; - assert_eq!( - esplora_script_fields(&spk_wsh, Network::Bitcoin).script_type, - "v0_p2wsh" - ); - // OP_RETURN - let opreturn = Builder::new() - .push_opcode(OP_RETURN) - .push_slice(b"hi") - .into_script(); - assert_eq!( - esplora_script_fields(opreturn.as_bytes(), Network::Bitcoin).script_type, - "op_return" - ); - // Bare multisig ends with CHECKMULTISIG - let multi = Builder::new() - .push_int(1) - .push_key(&PublicKey::from_slice(&G_COMPRESSED).unwrap()) - .push_int(1) - .push_opcode(OP_CHECKMULTISIG) - .into_script(); - assert_eq!( - esplora_script_fields(multi.as_bytes(), Network::Bitcoin).script_type, - "multisig" - ); - // Short script is not bare multisig - assert_eq!( - esplora_script_fields(&[0x51, 0x51], Network::Regtest).script_type, - "unknown" - ); - } -} diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index f73b2ffe6b..9df714e6b3 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -738,6 +738,50 @@ async fn pin_esplora_tx_status( assert_eq!(st, 404); } +async fn pin_esplora_script_classes(esplora_addr: SocketAddr, txid: &str) { + let (st, body) = http_get(esplora_addr, &format!("/tx/{txid}")).await; + assert_eq!(st, 200, "script-class tx: {body}"); + let txj: Value = serde_json::from_str(&body).unwrap(); + let types: Vec<&str> = txj["vout"] + .as_array() + .expect("vout") + .iter() + .map(|o| o["scriptpubkey_type"].as_str().expect("scriptpubkey_type")) + .collect(); + assert_eq!( + types, + [ + "p2pkh", + "p2sh", + "v0_p2wpkh", + "v0_p2wsh", + "v1_p2tr", + "p2pk", + "multisig", + "unknown", + "op_return", + ] + ); + assert!( + txj["vout"][7].get("scriptpubkey_address").is_none(), + "OP_TRUE has no address: {}", + txj["vout"][7] + ); + let addr = txj["vout"][2]["scriptpubkey_address"] + .as_str() + .expect("p2wpkh address"); + let (st, body) = http_get(esplora_addr, &format!("/address/{addr}")).await; + assert_eq!(st, 200, "GET /address p2wpkh: {body}"); + let (st, body) = http_get(esplora_addr, "/address/not-an-address").await; + assert_eq!(st, 404, "bad address: {body}"); + let (st, body) = http_get( + esplora_addr, + "/address/bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + ) + .await; + assert_eq!(st, 404, "mainnet address on regtest: {body}"); +} + async fn pin_esplora_tx_json_unknown_coinbase(esplora_addr: SocketAddr, cb_txid: &str) { let (st, body) = http_get(esplora_addr, &format!("/tx/{cb_txid}")).await; assert_eq!(st, 200, "tx json: {body}"); @@ -852,6 +896,16 @@ fn encode_tx(tx: &Transaction) -> String { } fn acs_spend(prev: Txid, input_sat: u64, fee: u64, spk: ScriptBuf) -> Transaction { + acs_spend_outputs( + prev, + vec![TxOut { + value: Amount::from_sat(input_sat - fee), + script_pubkey: spk, + }], + ) +} + +fn acs_spend_outputs(prev: Txid, output: Vec) -> Transaction { Transaction { version: TxVersion::TWO, lock_time: LockTime::ZERO, @@ -864,13 +918,86 @@ fn acs_spend(prev: Txid, input_sat: u64, fee: u64, spk: ScriptBuf) -> Transactio sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, witness: Witness::new(), }], - output: vec![TxOut { - value: Amount::from_sat(input_sat - fee), - script_pubkey: spk, - }], + output, } } +/// One anyone-can-spend parent, one vout per Esplora script class. +/// Output order is the `scriptpubkey_type` list the journey asserts. +fn script_class_outputs(input_sat: u64) -> Vec { + const G_COMPRESSED: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + const G_XONLY: [u8; 32] = [ + 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, 0x0b, + 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, 0xf8, + 0x17, 0x98, + ]; + let p2pkh = { + let mut v = vec![0x76, 0xa9, 0x14]; + v.extend_from_slice(&[0x11; 20]); + v.extend_from_slice(&[0x88, 0xac]); + v + }; + let p2sh = { + let mut v = vec![0xa9, 0x14]; + v.extend_from_slice(&[0x33; 20]); + v.push(0x87); + v + }; + let p2wpkh = { + let mut v = vec![0x00, 0x14]; + v.extend_from_slice(&[0x22; 20]); + v + }; + let p2wsh = { + let mut v = vec![0x00, 0x20]; + v.extend_from_slice(&[0xab; 32]); + v + }; + let p2tr = { + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&G_XONLY); + v + }; + let p2pk = { + let mut v = vec![0x21]; + v.extend_from_slice(&G_COMPRESSED); + v.push(0xac); + v + }; + let multisig = { + let mut v = vec![0x51, 0x21]; + v.extend_from_slice(&G_COMPRESSED); + v.extend_from_slice(&[0x51, 0xae]); + v + }; + let scripts = [ + p2pkh, + p2sh, + p2wpkh, + p2wsh, + p2tr, + p2pk, + multisig, + vec![0x51], + vec![0x6a, 0x02, b'h', b'i'], + ]; + let fee = 10_000u64; + let n = scripts.len(); + let each = (input_sat - fee) / (n as u64 - 1); + scripts + .into_iter() + .enumerate() + .map(|(i, raw)| TxOut { + value: Amount::from_sat(if i + 1 == n { 0 } else { each }), + script_pubkey: ScriptBuf::from_bytes(raw), + }) + .collect() +} + fn tx_wu(tx: &Transaction) -> u64 { tx.weight().to_wu() } @@ -1665,6 +1792,12 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { ); let replaced = jsonrpc(rpc_addr, "sendrawtransaction", json!([high_hex])).await; assert_eq!(replaced["result"], high_txid, "{replaced}"); + let high_value = 50_0000_0000 - (1_000 + inc); + let script_tx = acs_spend_outputs(high.compute_txid(), script_class_outputs(high_value)); + let script_hex = encode_tx(&script_tx); + let script_txid = script_tx.compute_txid().to_string(); + let script_sent = jsonrpc(rpc_addr, "sendrawtransaction", json!([script_hex])).await; + assert_eq!(script_sent["result"], script_txid, "{script_sent}"); let mem = jsonrpc(rpc_addr, "getrawmempool", json!([])).await; assert!( mempool_has(&mem, &high_txid), @@ -1880,6 +2013,7 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { &chain_last, &cpfp_parent_txid, &cpfp_child_txid, + &script_txid, ] { assert!(mempool_has(&mem, tid), "getrawmempool missing {tid}: {mem}"); } @@ -1919,6 +2053,10 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { // pool alone does not set a rate, so no target answers. let (st, body) = http_get(esplora_addr, "/fee-estimates").await; assert_eq!(st, 503, "GET /fee-estimates: {body}"); + assert!( + body.contains("fee estimates unavailable"), + "an empty estimator must not invent a target: {body}" + ); let metrics_in_ibd = pin_metrics_equal_rpc(health_addr, rpc_addr, false).await; assert!( @@ -1986,6 +2124,7 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { &at_min_txid, &chain_last, &cpfp_parent_txid, + &script_txid, ] { assert!( txs.iter().any(|t| t["txid"] == *tid), @@ -2009,6 +2148,16 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { pin_esplora_block_height_and_header(esplora_addr, new_hash, 107).await; pin_esplora_tx_status(esplora_addr, new_hash, &cb_txid, 107).await; pin_esplora_tx_json_unknown_coinbase(esplora_addr, &cb_txid).await; + pin_esplora_script_classes(esplora_addr, &script_txid).await; + let genesis_id = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest) + .block_hash() + .to_string(); + let (st, body) = http_get(esplora_addr, &format!("/block/{genesis_id}")).await; + assert_eq!(st, 200, "genesis block json: {body}"); + let genesis_json: Value = serde_json::from_str(&body).unwrap(); + assert!(genesis_json["previousblockhash"].is_null(), "{body}"); + let (st, body) = http_get(esplora_addr, &format!("/block/{}", "11".repeat(32))).await; + assert_eq!(st, 404, "unknown block: {body}"); pin_esplora_scripthash_pages(esplora_addr).await; let cb_val = (txs[0]["vout"][0]["value"].as_f64().unwrap() * 100_000_000.0).round() as u64; let immature = Transaction {