diff --git a/bin/bitcoin-rs/tests/support/policy_cases.rs b/bin/bitcoin-rs/tests/support/policy_cases.rs index 8651d9401..888368c30 100644 --- a/bin/bitcoin-rs/tests/support/policy_cases.rs +++ b/bin/bitcoin-rs/tests/support/policy_cases.rs @@ -245,6 +245,419 @@ fn signed(inputs: &[Coin], outputs: Vec, version: i32) -> Transaction { tx } +/// Compare the scoped submitpackage wire result and committed public state. +/// Numeric suffixes in native rejection details remain documented deviations. +fn submit_pair(pair: &mut Pair, txs: &[Transaction], extra: &[Value]) -> Value { + let mut args = vec![json!(txs.iter().map(serialize_hex).collect::>())]; + args.extend_from_slice(extra); + let params = Value::Array(args); + let core = pair + .core + .rpc("submitpackage", ¶ms) + .expect("Core submitpackage"); + let node = pair + .node + .rpc("submitpackage", ¶ms) + .expect("native submitpackage"); + assert_eq!(core["package_msg"], node["package_msg"], "{core} vs {node}"); + assert_eq!(core["replaced-transactions"], node["replaced-transactions"]); + for tx in txs { + let key = tx.compute_wtxid().to_string(); + let (reference, candidate) = (&core["tx-results"][&key], &node["tx-results"][&key]); + if let Some(error) = reference["error"].as_str() { + assert!(candidate["error"].is_string(), "{reference} vs {candidate}"); + assert_eq!(reference["txid"], candidate["txid"]); + // Existing native consensus/script errors intentionally omit Core's + // verbose script diagnostics. Fee errors retain their reason prefix. + if error.starts_with("min relay fee not met") + || error.starts_with("max feerate exceeded") + { + assert!( + error.starts_with(candidate["error"].as_str().expect("error string")), + "{reference} vs {candidate}" + ); + } + } else { + assert_eq!(reference, candidate, "{key}"); + } + } + let mut core_members = pair + .core + .rpc("getrawmempool", &json!([])) + .expect("Core pool") + .as_array() + .expect("members") + .clone(); + let mut node_members = pair + .node + .rpc("getrawmempool", &json!([])) + .expect("native pool") + .as_array() + .expect("members") + .clone(); + core_members.sort_by_key(Value::to_string); + node_members.sort_by_key(Value::to_string); + assert_eq!(core_members, node_members); + for tx in txs { + if node_members.contains(&json!(tx.compute_txid().to_string())) { + let entry = pair + .node + .rpc("getmempoolentry", &json!([tx.compute_txid().to_string()])) + .expect("accepted entry"); + if node["tx-results"][tx.compute_wtxid().to_string()]["other-wtxid"].is_null() { + assert_eq!( + entry["vsize"], + node["tx-results"][tx.compute_wtxid().to_string()]["vsize"] + ); + } + } + } + node +} + +/// A signed P2WSH child whose unexecuted branch still incurs sigop cost. +fn sigop_cpfp(coin: &Coin, fee: u64) -> [Transaction; 2] { + use bitcoin::hashes::Hash as _; + use bitcoin::opcodes::all::{OP_CHECKSIG, OP_CHECKSIGVERIFY, OP_ENDIF, OP_IF}; + use bitcoin::script::Builder; + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + use bitcoin::sighash::{EcdsaSighashType, SighashCache}; + let secp = Secp256k1::new(); + let key = SecretKey::from_slice(&[42; 32]).expect("test key"); + let public = + bitcoin::PublicKey::new(bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &key)); + let mut builder = Builder::new() + .push_key(&public) + .push_opcode(OP_CHECKSIGVERIFY) + .push_int(0) + .push_opcode(OP_IF); + for _ in 0..150 { + builder = builder.push_opcode(OP_CHECKSIG); + } + let script = builder.push_opcode(OP_ENDIF).push_int(1).into_script(); + let parent = signed( + std::slice::from_ref(coin), + vec![TxOut { + value: coin.1.value, + script_pubkey: script.to_p2wsh(), + }], + 2, + ); + let mut child = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: output(&parent, 0).0, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: coin.1.value - Amount::from_sat(fee), + script_pubkey: coin.1.script_pubkey.clone(), + }], + }; + let hash = SighashCache::new(&child) + .p2wsh_signature_hash(0, &script, coin.1.value, EcdsaSighashType::All) + .expect("child sighash"); + let signature = bitcoin::ecdsa::Signature { + signature: secp.sign_ecdsa(&Message::from_digest(hash.to_byte_array()), &key), + sighash_type: EcdsaSighashType::All, + }; + child.input[0].witness = Witness::from_slice(&[signature.to_vec(), script.into_bytes()]); + [parent, child] +} + +#[test] +fn submitpackage_cpfp_partial_acceptance_and_guards() { + let mut pair = Pair::new(); + submitpackage_cpfp_and_resident_members(&mut pair); + submitpackage_fee_sigop_and_truc_guards(&mut pair); + submitpackage_structure_burn_and_size_guards(&mut pair); + submitpackage_refuses_wider_tree(&mut pair); +} + +fn submitpackage_cpfp_and_resident_members(pair: &mut Pair) { + // A zero-fee signed parent is rejected by the unchanged pure preview. + let parent = transaction(&[pair.coins[0].clone()], 1, 0, 2); + let child = transaction(&[output(&parent, 0)], 1, 2_000, 2); + let (core_preview, node_preview) = pair.preview(&[parent.clone(), child.clone()]); + assert_eq!(core_preview[0]["allowed"], json!(false)); + assert_eq!(node_preview[0]["allowed"], json!(false)); + let accepted = submit_pair(pair, &[parent.clone(), child.clone()], &[]); + assert_eq!(accepted["package_msg"], json!("success")); + for process in [&mut pair.core, &mut pair.node] { + let template = process + .rpc("getblocktemplate", &json!([{"rules": ["segwit"]}])) + .expect("package mining template"); + let rows = template["transactions"] + .as_array() + .expect("template transactions"); + let parent_index = rows + .iter() + .position(|row| row["txid"] == json!(parent.compute_txid().to_string())) + .expect("parent selected"); + let child_index = rows + .iter() + .position(|row| row["txid"] == json!(child.compute_txid().to_string())) + .expect("child selected"); + assert!(parent_index < child_index); + assert_eq!(rows[parent_index]["fee"], json!(0)); + assert_eq!(rows[child_index]["fee"], json!(2_000)); + } + for process in [&mut pair.core, &mut pair.node] { + let named = process.rpc("submitpackage", &json!({"package": [serialize_hex(&parent), serialize_hex(&child)], "maxfeerate": "0", "maxburnamount": "0"})).expect("named parameters"); + assert_eq!(named["package_msg"], json!("success")); + assert!( + named["tx-results"][child.compute_wtxid().to_string()]["fees"]["effective-feerate"] + .is_null() + ); + } + let mut variant = child.clone(); + variant.input[0].witness = Witness::from_slice(&[vec![0]]); + submit_pair(pair, &[variant], &[]); // resident txid ignores another witness + submit_pair(pair, &[parent, child], &[]); // resident fees are not reused + + // Individually admissible members use their own effective fee rates. + let parent = transaction(&[pair.coins[1].clone()], 1, 1_000, 2); + let child = transaction(&[output(&parent, 0)], 1, 2_000, 2); + submit_pair(pair, &[parent, child], &[]); + + // Public partial acceptance: the good parent survives a bad child. + let parent = transaction(&[pair.coins[2].clone()], 1, 1_000, 2); + let mut child = transaction(&[output(&parent, 0)], 1, 2_000, 2); + child.input[0].script_sig = ScriptBuf::new(); + let partial = submit_pair(pair, &[parent, child], &[]); + assert_eq!(partial["package_msg"], json!("transaction failed")); + + // A resident parent cannot donate fees to a new zero-fee child. + let parent = transaction(&[pair.coins[3].clone()], 1, 1_000, 2); + submit_pair(pair, std::slice::from_ref(&parent), &[]); + let child = transaction(&[output(&parent, 0)], 1, 0, 2); + submit_pair(pair, &[parent.clone(), child], &[]); + let paying_child = transaction(&[output(&parent, 0)], 1, 2_000, 2); + submit_pair(pair, &[parent, paying_child], &[]); + + let parent = transaction(&[pair.coins[4].clone()], 1, 0, 2); + let child = transaction(&[output(&parent, 0)], 1, 1, 2); + submit_pair(pair, &[parent, child], &[]); // aggregate underpayment +} + +fn submitpackage_fee_sigop_and_truc_guards(pair: &mut Pair) { + // The public maxfeerate guard uses modified fees, not just actual fees. + let parent = transaction(&[pair.coins[5].clone()], 1, 1_000, 2); + for process in [&mut pair.core, &mut pair.node] { + process + .rpc( + "prioritisetransaction", + &json!([parent.compute_txid().to_string(), 0, 10_000]), + ) + .expect("priority"); + } + submit_pair(pair, &[parent], &[json!(0.00001)]); + let low_fee = transaction(&[pair.coins[13].clone()], 1, 10, 2); + submit_pair(pair, &[low_fee], &[json!(0.000_000_01)]); // own floor before caller cap + + let sigop_package = sigop_cpfp(&pair.coins[14], 700); + assert!(sigop_package.iter().map(Transaction::vsize).sum::() < 700); + assert_eq!( + submit_pair(pair, &sigop_package, &[])["package_msg"], + json!("transaction failed") + ); + let sigop_package = sigop_cpfp(&pair.coins[14], 2_000); + let accepted = submit_pair(pair, &sigop_package, &[]); + assert_eq!(accepted["package_msg"], json!("success")); + assert_eq!( + accepted["tx-results"][sigop_package[1].compute_wtxid().to_string()]["vsize"], + json!(775) // 151 witness sigops plus four legacy output sigops + ); + + // TRUC ephemeral dust is admissible only with the paying dust spender. + let coin = pair.coins[6].clone(); + let parent = signed( + std::slice::from_ref(&coin), + vec![ + TxOut { + value: Amount::from_sat(1), + script_pubkey: coin.1.script_pubkey.clone(), + }, + TxOut { + value: coin.1.value - Amount::from_sat(1), + script_pubkey: coin.1.script_pubkey.clone(), + }, + ], + 3, + ); + let child = transaction(&[output(&parent, 0), output(&parent, 1)], 1, 2_000, 3); + submit_pair(pair, &[parent, child], &[]); + + let coin = pair.coins[10].clone(); + let parent = signed( + std::slice::from_ref(&coin), + vec![ + TxOut { + value: Amount::from_sat(1), + script_pubkey: coin.1.script_pubkey.clone(), + }, + TxOut { + value: coin.1.value - Amount::from_sat(1), + script_pubkey: coin.1.script_pubkey.clone(), + }, + ], + 3, + ); + let child = transaction(&[output(&parent, 1)], 1, 2_000, 3); + assert_eq!( + submit_pair(pair, &[parent, child], &[])["package_msg"], + json!("unspent-dust") + ); + + let parent = transaction(&[pair.coins[11].clone()], 1, 0, 3); + let child = transaction(&[output(&parent, 0)], 1, 2_000, 2); + // Core includes transaction-specific TRUC diagnostics; the native profile + // retains the typed reason. Both must reject without accepting the parent. + let raws = json!([[serialize_hex(&parent), serialize_hex(&child)]]); + for process in [&mut pair.core, &mut pair.node] { + let before = process + .rpc("getrawmempool", &json!([false, true])) + .expect("before TRUC refusal"); + let reply = process + .rpc("submitpackage", &raws) + .expect("TRUC package result"); + assert!( + reply["package_msg"] + .as_str() + .expect("message") + .starts_with("TRUC-violation"), + "{reply}" + ); + assert_eq!( + process + .rpc("getrawmempool", &json!([false, true])) + .expect("after TRUC refusal"), + before + ); + } +} + +fn submitpackage_structure_burn_and_size_guards(pair: &mut Pair) { + // Reversed order, duplicates, and unrelated members fail before admission. + let parent = transaction(&[pair.coins[7].clone()], 1, 0, 2); + let child = transaction(&[output(&parent, 0)], 1, 2_000, 2); + let unrelated = transaction(&[pair.coins[12].clone()], 1, 2_000, 2); + for txs in [ + vec![child.clone(), parent.clone()], + vec![parent.clone(), parent.clone()], + vec![parent.clone(), unrelated], + ] { + for process in [&mut pair.core, &mut pair.node] { + let before = process + .rpc("getrawmempool", &json!([false, true])) + .expect("before topology refusal"); + assert!(matches!( + process.rpc( + "submitpackage", + &json!([txs.iter().map(serialize_hex).collect::>()]) + ), + Err(Error::Rpc { code: -25, .. }) + )); + assert_eq!( + process + .rpc("getrawmempool", &json!([false, true])) + .expect("after refusal"), + before + ); + } + } + let conflicting = transaction(&[pair.coins[7].clone(), output(&parent, 0)], 1, 2_000, 2); + assert_eq!( + submit_pair(pair, &[parent.clone(), conflicting], &[])["package_msg"], + json!("conflict-in-package") + ); + // Burning is checked for the entire request before the good parent commits. + let mut burn = child; + burn.output.push(TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x6a]), + }); + for process in [&mut pair.core, &mut pair.node] { + assert!(matches!( + process.rpc( + "submitpackage", + &json!([[serialize_hex(&parent), serialize_hex(&burn)]]) + ), + Err(Error::Rpc { code: -25, .. }) + )); + assert!(matches!( + process.rpc("submitpackage", &json!([[]])), + Err(Error::Rpc { code: -8, .. }) + )); + assert!(matches!( + process.rpc("submitpackage", &json!([vec![serialize_hex(&parent); 26]])), + Err(Error::Rpc { code: -8, .. }) + )); + } + let coin = output(&parent, 0); + let allowed_burn = signed( + std::slice::from_ref(&coin), + vec![ + TxOut { + value: coin.1.value - Amount::from_sat(2_001), + script_pubkey: coin.1.script_pubkey.clone(), + }, + TxOut { + value: Amount::from_sat(1), + script_pubkey: ScriptBuf::from_bytes(vec![0x6a]), + }, + ], + 2, + ); + submit_pair( + pair, + &[parent, allowed_burn], + &[json!(0), json!("0.00000001")], + ); + // Large signed CPFP below the boundary, then refusal above it. + let parent = transaction(&[pair.coins[8].clone()], 2, 0, 2); + let child = transaction(&[output(&parent, 0)], 2_900, 300_000, 2); + let weight = parent.weight().to_wu() + child.weight().to_wu(); + assert!((390_000..=404_000).contains(&weight)); + assert_eq!( + submit_pair(pair, &[parent, child], &[])["package_msg"], + json!("success") + ); + let parent = transaction(&[pair.coins[9].clone()], 2, 0, 2); + let child = transaction(&[output(&parent, 0)], 3_000, 300_000, 2); + assert!(parent.weight().to_wu() + child.weight().to_wu() > 404_000); + let oversized = submit_pair(pair, &[parent, child], &[]); + assert_eq!(oversized["package_msg"], json!("package-too-large")); +} + +fn submitpackage_refuses_wider_tree(pair: &mut Pair) { + // The scoped API refuses a valid wider tree before any member commits. + let first = transaction(&[pair.coins[15].clone()], 1, 0, 2); + let second = transaction(&[pair.coins[16].clone()], 1, 0, 2); + let child = transaction(&[output(&first, 0), output(&second, 0)], 1, 4_000, 2); + let args = json!([[ + serialize_hex(&first), + serialize_hex(&second), + serialize_hex(&child) + ]]); + let before = pair + .node + .rpc("getrawmempool", &json!([false, true])) + .expect("before wider package"); + assert!( + matches!(pair.node.rpc("submitpackage", &args), Err(Error::Rpc { code: -25, ref message, .. }) if message.contains("at most one parent and one child")) + ); + assert_eq!( + pair.node + .rpc("getrawmempool", &json!([false, true])) + .expect("after wider refusal"), + before + ); +} + #[test] fn replacement_graph_merges_splits_and_conflict_cluster_boundary() { let mut pair = Pair::new(); diff --git a/crates/mempool/src/gateway.rs b/crates/mempool/src/gateway.rs index fa1c8c218..578302b75 100644 --- a/crates/mempool/src/gateway.rs +++ b/crates/mempool/src/gateway.rs @@ -202,6 +202,7 @@ pub(crate) enum AdmissionMode { SingleSubmit, PackageTest, PackageSubmit, + PackageRpcSubmit(Option), } #[allow( @@ -216,6 +217,18 @@ enum ReplacementStage { } impl PreparedAdmission { + pub(crate) fn check_package_max_feerate(&mut self, maximum: Option) { + if self.rejection.is_none() + && maximum + .is_some_and(|max| self.fact.effective_fee_rate.is_some_and(|rate| rate > max)) + { + self.reject( + AdmitError::Policy(crate::standardness::AcceptanceRejectReason::MaxFeeExceeded), + RejectScope::Transaction, + ); + } + } + fn reject(&mut self, error: AdmitError, scope: RejectScope) { self.replacement = ReplacementStage::Rejected; self.fact.allowed = Some(false); @@ -359,9 +372,12 @@ impl PreparedAdmission { } // The caller's cap gates the bump like scripts do — Core applies // `client_maxfeerate` in PreChecks, before any trim can touch the - // floor. A capped candidate keeps its Full report but earns nothing. + // floor. The gate uses the transaction's actual base fee: a policy + // discount or surcharge must not change the client fee-limit result. + // A capped candidate keeps its Full report but earns nothing. + let fee_for_max = self.fact.base_fee.unwrap_or(0); if crate::standardness::exceeds_max_feerate( - self.fact.base_fee.unwrap_or(0), + fee_for_max, self.fact.vsize, request.max_feerate_sat_per_kvb, ) { @@ -1182,7 +1198,11 @@ impl MempoolGateway { _ => LimitEnforcement::Full, }; let deferred = enforcement == LimitEnforcement::Deferred; - let floor = if deferred || mode == AdmissionMode::PackageSubmit { + let floor = if deferred + || matches!( + mode, + AdmissionMode::PackageSubmit | AdmissionMode::PackageRpcSubmit(_) + ) { None } else { Some(pool.min_fee_sat_per_kvb(bitcoin_rs_primitives::unix_time_secs())) @@ -1237,7 +1257,9 @@ impl MempoolGateway { } if matches!( mode, - AdmissionMode::PackageTest | AdmissionMode::PackageSubmit + AdmissionMode::PackageTest + | AdmissionMode::PackageSubmit + | AdmissionMode::PackageRpcSubmit(_) ) { // Package previews and native submissions forbid replacement; // an already resident tx retains its duplicate result here. diff --git a/crates/mempool/src/lib.rs b/crates/mempool/src/lib.rs index 0a0217c90..843e71ab2 100644 --- a/crates/mempool/src/lib.rs +++ b/crates/mempool/src/lib.rs @@ -73,4 +73,5 @@ pub use rbf::{RbfError, required_fee}; pub use rbf::{ReplacementCandidate, ReplacementPlan}; pub use reader::MempoolReader; pub use standardness::{StandardnessError, StandardnessPolicy, is_standard_tx}; +pub use submit_package::{PackageSubmission, PackageTxResult}; pub use truc::TrucError; diff --git a/crates/mempool/src/package.rs b/crates/mempool/src/package.rs index 9bcd69545..f4575a2be 100644 --- a/crates/mempool/src/package.rs +++ b/crates/mempool/src/package.rs @@ -54,6 +54,7 @@ pub(crate) fn unfinished(tx: &Tx) -> TxAcceptanceFact { weight: tx.weight(), sigop_cost: 0, base_fee: None, + modified_fee: None, effective_fee_rate: None, reject_reason: None, } @@ -182,6 +183,14 @@ pub(crate) fn finish_package( }; for (index, (job, request)) in prepared.iter_mut().zip(requests).enumerate() { job.verify_policy(request); + if let crate::gateway::AdmissionMode::PackageRpcSubmit(maximum) = mode + // Core's singleton PreChecks applies its own floor before the + // client cap. Multi-transaction caps precede aggregate fee checks. + && (txs.len() > 1 + || aggregate_error != Some(AcceptanceRejectReason::MinRelayFeeNotMet)) + { + job.check_package_max_feerate(maximum); + } if job.fact.allowed == Some(false) { facts.results[index] = job.fact.clone(); return facts; @@ -208,11 +217,17 @@ pub(crate) fn finish_package( Err(error) => { // Preserve the existing preview error surface. Native submission // reports pool/graph failures without pretending to run replacement. - facts.package_error = Some(if mode == crate::gateway::AdmissionMode::PackageSubmit { - AcceptanceRejectReason::Mempool(error.into_pool_error()) - } else { - AcceptanceRejectReason::Replacement(error) - }); + facts.package_error = Some( + if matches!( + mode, + crate::gateway::AdmissionMode::PackageSubmit + | crate::gateway::AdmissionMode::PackageRpcSubmit(_) + ) { + AcceptanceRejectReason::Mempool(error.into_pool_error()) + } else { + AcceptanceRejectReason::Replacement(error) + }, + ); return facts; } } diff --git a/crates/mempool/src/package/tests.rs b/crates/mempool/src/package/tests.rs index 7183a66bf..89a5c7e06 100644 --- a/crates/mempool/src/package/tests.rs +++ b/crates/mempool/src/package/tests.rs @@ -530,6 +530,286 @@ fn gateway() -> MempoolGateway { gateway_observed(None) } +#[test] +fn rpc_cpfp_commits_one_ordered_batch_with_actual_fees_and_pure_preview() -> TestResult { + use crate::PackageTxResult; + let seen = Arc::new(Seen::default()); + let gateway = gateway_observed(Some(seen.clone())); + gateway.pool.write().limits.min_relay_fee_sat_per_kvb = 1_000; + let parent = spend(&[coin(830)], &[100_000], false); + let child = spend(&[OutPoint::new(parent.txid(), 0)], &[98_000], true); + let txs = [parent, child]; + let chain = Coins(vec![( + coin(830), + TxOut { + value: Amount::from_sat(100_000), + script_pubkey: Script::from_bytes(vec![0x51]), + }, + )]); + let preview = gateway.preview_transactions(&txs, None, &chain)?; + assert_eq!( + preview.results[0].reject_reason, + Some(AcceptanceRejectReason::MinRelayFeeNotMet) + ); + let history = gateway.read().estimator_history(); + let submitted = gateway.submit_rpc_package(&txs, None, unix_time_secs(), &chain)?; + assert_eq!(submitted.package_error, None); + let mut total_size = 0_u32; + for (tx, row) in txs.iter().zip(&submitted.tx_results) { + let PackageTxResult::Accepted { + vsize, + base_fee, + effective_includes, + .. + } = row + else { + return Err("package was not accepted".into()); + }; + total_size += *vsize; + assert_eq!( + *base_fee, + if tx.txid() == txs[0].txid() { 0 } else { 2_000 } + ); + let expected_includes: Vec<_> = txs.iter().map(Tx::wtxid).collect(); + assert_eq!(effective_includes, &expected_includes); + } + for row in &submitted.tx_results { + let PackageTxResult::Accepted { + effective_fee_rate, .. + } = row + else { + unreachable!() + }; + // Every member committed by the atomic plan reports the package's + // aggregate rate over the same full set. + assert_eq!(*effective_fee_rate, Some(2_000_000 / u64::from(total_size))); + } + let batches = seen.0.lock(); + assert_eq!(batches.len(), 1); + assert_eq!( + batches[0] + .result + .changes + .iter() + .map(|change| change.txid) + .collect::>(), + txs.iter() + .map(|tx| Hash256::from(tx.txid())) + .collect::>() + ); + assert_eq!(gateway.read().sequence_number(), 2); + assert_eq!(gateway.read().estimator_history(), history); + drop(batches); + let resident = gateway.submit_rpc_package(&txs, None, unix_time_secs(), &chain)?; + assert!(resident.tx_results.iter().all(|row| matches!( + row, + PackageTxResult::Accepted { + effective_fee_rate: None, + .. + } + ))); + assert_eq!(seen.0.lock().len(), 1); + Ok(()) +} + +#[test] +fn rpc_package_keeps_independent_parent_but_never_uses_its_fees_twice() -> TestResult { + use crate::PackageTxResult; + for invalid_script in [false, true] { + let gateway = gateway(); + gateway.pool.write().limits.min_relay_fee_sat_per_kvb = 1_000; + let parent = spend(&[coin(831)], &[98_000], false); + let mut child = spend( + &[OutPoint::new(parent.txid(), 0)], + &[if invalid_script { 96_000 } else { 98_000 }], + true, + ); + if invalid_script { + child.inputs[0].witness = Witness::from_stack(vec![vec![0x00]]); + } + let chain = Coins(vec![( + coin(831), + TxOut { + value: Amount::from_sat(100_000), + script_pubkey: Script::from_bytes(vec![0x51]), + }, + )]); + let expected = if invalid_script { + SubmitError::Consensus + } else { + AcceptanceRejectReason::MinRelayFeeNotMet.into() + }; + let result = + gateway.submit_rpc_package(&[parent.clone(), child], None, unix_time_secs(), &chain)?; + assert!(matches!( + result.tx_results[0], + PackageTxResult::Accepted { .. } + )); + assert_eq!(result.tx_results[1], PackageTxResult::Rejected(expected)); + assert_eq!(gateway.read().len(), 1); + assert!(gateway.read().contains_txid(&parent.txid())); + assert_eq!(gateway.read().sequence_number(), 1); + } + Ok(()) +} + +#[test] +fn rpc_package_underpayment_priority_cap_and_unsupported_shapes_mutate_nothing() -> TestResult { + let gateway = gateway(); + gateway.pool.write().limits.min_relay_fee_sat_per_kvb = 1_000; + let parent = spend(&[coin(832)], &[100_000], false); + let child = spend(&[OutPoint::new(parent.txid(), 0)], &[99_999], true); + let chain = Coins(vec![( + coin(832), + TxOut { + value: Amount::from_sat(100_000), + script_pubkey: Script::from_bytes(vec![0x51]), + }, + )]); + let txs = [parent.clone(), child.clone()]; + let history = gateway.read().estimator_history(); + let result = gateway.submit_rpc_package(&txs, None, unix_time_secs(), &chain)?; + assert!( + result + .tx_results + .iter() + .all(|row| matches!(row, crate::PackageTxResult::Rejected(_))) + ); + assert!(gateway.read().is_empty()); + assert_eq!(gateway.read().sequence_number(), 0); + assert_eq!(gateway.read().estimator_history(), history); + assert_eq!( + gateway.submit_rpc_package(&[child, parent.clone()], None, 0, &chain), + Err(AcceptanceRejectReason::PackageTopology.into()) + ); + assert_eq!( + gateway.submit_rpc_package(&[parent.clone(), parent.clone()], None, 0, &chain), + Err(AcceptanceRejectReason::PackageTopology.into()) + ); + assert_eq!( + gateway.submit_rpc_package( + &[parent.clone(), parent.clone(), parent.clone()], + None, + 0, + &chain + ), + Err(AcceptanceRejectReason::PackageUnsupported.into()) + ); + assert_eq!( + gateway.submit_rpc_package( + &[parent.clone(), parent.clone(), parent.clone()], + None, + 0, + &chain + ), + Err(AcceptanceRejectReason::PackageUnsupported.into()) + ); + gateway.prioritise(parent.txid(), 10_000)?; + let capped = gateway.submit_rpc_package(&[parent], Some(1_000), unix_time_secs(), &chain)?; + assert_eq!( + capped.tx_results, + vec![crate::PackageTxResult::Rejected( + AcceptanceRejectReason::MaxFeeExceeded.into() + )] + ); + assert!(gateway.read().is_empty()); + assert_eq!(gateway.read().sequence_number(), 0); + Ok(()) +} + +#[test] +fn rpc_package_retries_fee_revision_and_chain_fences_without_membership_leaks() -> TestResult { + struct MovingChain<'a> { + gateway: &'a MempoolGateway, + chain: Coins, + marker: bitcoin_rs_primitives::Txid, + } + impl AdmissionChain for MovingChain<'_> { + fn snapshot(&self, tx: &Tx) -> Option { + self.gateway.prioritise(self.marker, 1).ok()?; + self.chain.snapshot(tx) + } + } + let gateway = Arc::new(gateway()); + let parent = spend(&[coin(833)], &[100_000], false); + let child = spend(&[OutPoint::new(parent.txid(), 0)], &[98_000], true); + let chain = MovingChain { + gateway: &gateway, + chain: Coins(vec![( + coin(833), + TxOut { + value: Amount::from_sat(100_000), + script_pubkey: Script::from_bytes(vec![0x51]), + }, + )]), + marker: coin(900).txid, + }; + let txs = [parent, child]; + let result = gateway.submit_rpc_package(&txs, None, unix_time_secs(), &chain)?; + assert_eq!( + result.tx_results, + vec![crate::PackageTxResult::Rejected(SubmitError::RetryExhausted); 2] + ); + assert_eq!(gateway.read().sequence_number(), 0); + assert!(gateway.read().is_empty()); + let change = gateway.begin_chain_change()?; + let result = gateway.submit_rpc_package(&txs, None, unix_time_secs(), &chain.chain)?; + assert_eq!( + result.tx_results, + vec![crate::PackageTxResult::Rejected(SubmitError::RetryExhausted); 2] + ); + assert!(gateway.read().is_empty()); + change.finish()?; + Ok(()) +} + +#[test] +fn rpc_package_capacity_and_replacement_refusals_preserve_authoritative_state() -> TestResult { + let gateway = gateway(); + gateway.pool.write().limits.min_relay_fee_sat_per_kvb = 1_000; + let parent = spend(&[coin(834)], &[100_000], false); + let child = spend(&[OutPoint::new(parent.txid(), 0)], &[98_000], true); + let chain = Coins(vec![( + coin(834), + TxOut { + value: Amount::from_sat(100_000), + script_pubkey: Script::from_bytes(vec![0x51]), + }, + )]); + gateway.pool.write().limits.max_total_bytes = parent.vsize() + child.vsize() - 1; + let result = gateway.submit_rpc_package(&[parent, child], None, unix_time_secs(), &chain)?; + assert_eq!( + result.package_error, + Some(AcceptanceRejectReason::Mempool(crate::MempoolError::Full)) + ); + assert!(gateway.read().is_empty()); + assert_eq!(gateway.read().sequence_number(), 0); + + gateway.pool.write().limits.max_total_bytes = 0; + let resident = spend(&[coin(834)], &[98_000], false); + gateway.submit_local_transaction( + Arc::new(resident.clone()), + crate::LocalOrigin::Rpc, + None, + unix_time_secs(), + &chain, + )?; + let history = gateway.read().estimator_history(); + let replacement = spend(&[coin(834)], &[97_000], false); + let child = spend(&[OutPoint::new(replacement.txid(), 0)], &[95_000], true); + let result = + gateway.submit_rpc_package(&[replacement, child], None, unix_time_secs(), &chain)?; + assert_eq!( + result.tx_results[0], + crate::PackageTxResult::Rejected(AcceptanceRejectReason::MempoolConflict.into()) + ); + assert!(gateway.read().contains_txid(&resident.txid())); + assert_eq!(gateway.read().len(), 1); + assert_eq!(gateway.read().sequence_number(), 1); + assert_eq!(gateway.read().estimator_history(), history); + Ok(()) +} + fn gateway_observed(observer: Option>) -> MempoolGateway { MempoolGateway::new( Arc::new(parking_lot::RwLock::new(Mempool::new(MempoolLimits { @@ -935,3 +1215,84 @@ fn native_package_commit_survives_its_stale_resident_expiring() -> TestResult { ); Ok(()) } + +/// Single-transaction `maxfeerate` gates on the actual base fee, never the +/// modified fee: a policy discount must not smuggle an over-cap fee in, +/// and a surcharge must not refuse an under-cap fee. +#[test] +fn single_tx_maxfeerate_gates_on_base_fee_not_modified_fee() -> TestResult { + use crate::standardness::AcceptanceRejectReason; + // Gauge the fixture shape once: one anyone-can-spend input and output. + let gauge = gateway(); + gauge.pool.write().limits.min_relay_fee_sat_per_kvb = 1; + let tx = spend(&[coin(840)], &[98_000], true); + // The spent output is a witness program so the native single path + // verifies the pushed witness instead of refusing a bare key. + let chain = Coins(vec![(coin(840), output(100_000))]); + gauge.submit_local_transaction( + Arc::new(tx.clone()), + crate::LocalOrigin::Rpc, + None, + 0, + &chain, + )?; + let vsize = gauge + .read() + .entry_by_txid(&tx.txid()) + .ok_or("fixture missing")? + .vsize; + let base_rate = 2_000_000 / u64::from(vsize); + // Wide margins around both rates so integer rounding cannot matter: + // the discount drops the modified fee to 100 sats, the surcharge lifts + // it to 100_000 sats, while the base fee stays 2_000 sats. + let below_base = base_rate / 2; + let above_base = base_rate * 2 + 1_000; + // Real margins, immune to integer-division equality: the discounted fee + // (100 sats) sits below the cap's required fee, the surcharge-implied + // fee (100_000 sats) sits above the higher cap's. + assert!(crate::rbf::required_fee(below_base, vsize).is_ok_and(|limit| limit > 100)); + assert!(above_base < 100_000_000 / u64::from(vsize)); + + // Discount: modified rate is under the cap, base rate is over it. + let discounted = gateway(); + discounted.pool.write().limits.min_relay_fee_sat_per_kvb = 1; + discounted.prioritise(tx.txid(), -1_900)?; + let preview = + discounted.preview_transactions(std::slice::from_ref(&tx), Some(below_base), &chain)?; + assert_eq!(preview.results[0].allowed, Some(false)); + assert_eq!( + preview.results[0].reject_reason, + Some(AcceptanceRejectReason::MaxFeeExceeded), + "preview must agree with submit on the base-fee cap" + ); + assert_eq!( + discounted.submit_local_transaction( + Arc::new(tx.clone()), + crate::LocalOrigin::Rpc, + Some(below_base), + 0, + &chain, + ), + Err(SubmitError::Policy(AcceptanceRejectReason::MaxFeeExceeded)), + "a discount must not admit an over-cap base fee" + ); + assert!(discounted.read().is_empty()); + + // Surcharge: modified rate is over the cap, base rate is under it. + let surcharged = gateway(); + surcharged.pool.write().limits.min_relay_fee_sat_per_kvb = 1; + surcharged.prioritise(tx.txid(), 98_000)?; + let preview = + surcharged.preview_transactions(std::slice::from_ref(&tx), Some(above_base), &chain)?; + assert_eq!(preview.results[0].allowed, Some(true)); + assert_eq!(preview.results[0].reject_reason, None); + surcharged.submit_local_transaction( + Arc::new(tx), + crate::LocalOrigin::Rpc, + Some(above_base), + 0, + &chain, + )?; + assert_eq!(surcharged.read().len(), 1); + Ok(()) +} diff --git a/crates/mempool/src/standardness.rs b/crates/mempool/src/standardness.rs index e59da3340..055501902 100644 --- a/crates/mempool/src/standardness.rs +++ b/crates/mempool/src/standardness.rs @@ -134,6 +134,8 @@ pub struct TxAcceptanceFact { pub sigop_cost: u32, /// Base fee when acceptance accounting succeeded far enough to know it. pub base_fee: Option, + /// Modified fee in satoshis when prioritisation is applied. + pub modified_fee: Option, /// Modified fee rate in sat/kvB from the captured policy state. pub effective_fee_rate: Option, /// Rejection reason when `allowed` is false. @@ -188,6 +190,9 @@ pub enum AcceptanceRejectReason { /// Native submission requires a child and its independent direct parents. #[error("package-not-child-with-parents")] PackageTopology, + /// Public package submission currently supports singletons and 1p1c only. + #[error("package submission supports at most one parent and one child")] + PackageUnsupported, /// Two package transactions spend the same outpoint. #[error("conflict-in-package")] PackageConflict, @@ -302,12 +307,16 @@ pub(crate) fn evaluate_one( // `None` omits the floor comparison entirely (Core `bypassLimits`): // a zero floor still rejects a tx whose prioritisation delta drags // its modified fee negative, which bypassing must not do. + let modified_fee_res = pool.modified_fee_for(txid, context.fee); + let modified_fee = modified_fee_res.as_ref().ok().copied(); + // The caller's cap gates on the transaction's actual base fee, exactly + // like submission: a policy discount or surcharge must not change the + // client fee-limit result, so preview and submit agree on the same cap. + let fee_for_max = context.fee; + let fee_rejection = match mempool_min_fee_sat_per_kvb { None => None, - Some(floor) => match ( - pool.modified_fee_for(txid, context.fee), - crate::rbf::required_fee(floor, vsize), - ) { + Some(floor) => match (modified_fee_res, crate::rbf::required_fee(floor, vsize)) { (Ok(fee), Ok(required)) => { (fee < required).then_some(AcceptanceRejectReason::MinRelayFeeNotMet) } @@ -333,7 +342,7 @@ pub(crate) fn evaluate_one( Some(AcceptanceRejectReason::TooManySigops) } else if let Some(reason) = fee_rejection { Some(reason) - } else if exceeds_max_feerate(context.fee, vsize, max_feerate_sat_per_kvb) { + } else if exceeds_max_feerate(fee_for_max, vsize, max_feerate_sat_per_kvb) { Some(AcceptanceRejectReason::MaxFeeExceeded) } else if !bip68_final(pool, tx, finality) { Some(AcceptanceRejectReason::NonBip68Final) @@ -341,6 +350,18 @@ pub(crate) fn evaluate_one( None }; + let effective_fee_rate = modified_fee.and_then(|fee| { + let clamped = fee.max(0); + (vsize != 0) + .then(|| { + clamped + .checked_mul(1_000) + .map(|prod| prod / i128::from(vsize)) + }) + .flatten() + .and_then(|rate| u64::try_from(rate).ok()) + }); + TxAcceptanceFact { txid, wtxid, @@ -349,11 +370,8 @@ pub(crate) fn evaluate_one( weight, sigop_cost: context.sigop_cost, base_fee: Some(context.fee), - effective_fee_rate: pool - .modified_fee_for(txid, context.fee) - .ok() - .and_then(|fee| (vsize != 0).then(|| fee * 1_000 / i128::from(vsize))) - .and_then(|rate| u64::try_from(rate).ok()), + modified_fee, + effective_fee_rate, reject_reason: reject, } } @@ -785,4 +803,104 @@ mod tests { Err(StandardnessError::NonStandardOutput) ); } + + #[test] + fn signed_delta_clamp_prevents_maxfeerate_rejection_and_retains_effective_rate() + -> Result<(), Box> { + let mut pool = Mempool::new(crate::MempoolLimits::default()); + let tx = standard_tx(2); + let txid = tx.txid(); + + // Base fee: 1_000 sats, negative delta of -3_000 sats -> modified fee: -2_000 sats. + pool.prioritise(txid, -3_000)?; + assert_eq!(pool.modified_fee_for(txid, 1_000)?, -2_000); + + let context = PackageTxContext { + fee: 1_000, + vsize: 100, + sigop_cost: 0, + missing_inputs: false, + }; + + let fact = evaluate_one( + &pool, + &policy(), + &tx, + context, + Some(10_000), // max_feerate_sat_per_kvb + None, + &Bip68Admission::default(), + ); + + // Clamping negative modified fee to max(0) ensures: + // 1. Never maps a negative delta to u64::MAX, so max feerate is not exceeded. + assert_ne!( + fact.reject_reason, + Some(AcceptanceRejectReason::MaxFeeExceeded) + ); + // 2. Modified fee is tracked as signed -2000. + assert_eq!(fact.modified_fee, Some(-2_000)); + // 3. Clamped effective fee rate produces Some(0), not None. + assert_eq!(fact.effective_fee_rate, Some(0)); + Ok(()) + } + + #[test] + fn three_vsize_meanings_on_sigop_heavy_tx() -> Result<(), Box> { + use crate::accounting::prepared_context; + use crate::entry::MempoolEntry; + use alloc::sync::Arc; + + let mut tx = standard_tx(2); + // Add 50 OP_CHECKSIG opcodes in an output script (each counts as 4 sigop cost in consensus). + let sigop_script = Script::from_bytes(vec![opcode::OP_CHECKSIG; 50]); + tx.outputs.push(TxOut { + value: Amount::from_sat(50_000), + script_pubkey: sigop_script, + }); + + let prevout = ( + tx.inputs[0].previous_output, + TxOut { + value: Amount::from_sat(200_000), + script_pubkey: p2pkh(&[7_u8; 20]).into(), + }, + ); + + let context = prepared_context(&tx, &[prevout], false); + // 50 OP_CHECKSIG opcodes in added output + 1 in P2PKH output = 51 sigops = 204 sigop cost. + assert_eq!(context.sigop_cost, 204); + + let unadjusted_weight = tx.weight(); + let vsize_bip141 = u32::try_from(tx.vsize())?; + assert_eq!( + vsize_bip141, + u32::try_from(Tx::vsize_from_weight(unadjusted_weight))? + ); + + // Policy-adjusted virtual size reflects the heavy sigop cost: + // 204 sigop cost * 20 = 4,080 WU; 4,080 / 4 = 1,020 vB. + let vsize_adjusted = context.vsize; + assert_eq!(vsize_adjusted, 1_020); + assert!(vsize_adjusted > vsize_bip141); + + let entry = MempoolEntry::new( + Arc::new(tx.clone()), + vsize_adjusted, + context.fee, + 1, + 1, + context.sigop_cost, + ); + + // 1. entry.vsize = vsize_adjusted = context.vsize (sigop-adjusted entry vsize) + assert_eq!(entry.vsize, vsize_adjusted); + assert_eq!(entry.vsize, 1_020); + // 2. entry.bip141_vsize = vsize_bip141 = tx.vsize() unadjusted + assert_eq!(entry.bip141_vsize, vsize_bip141); + assert_eq!(entry.bip141_vsize, u32::try_from(tx.vsize())?); + // 3. Consensus weight unadjusted + assert_eq!(entry.weight, unadjusted_weight); + Ok(()) + } } diff --git a/crates/mempool/src/submit_package.rs b/crates/mempool/src/submit_package.rs index 6cd34314d..afb1aead2 100644 --- a/crates/mempool/src/submit_package.rs +++ b/crates/mempool/src/submit_package.rs @@ -1,4 +1,4 @@ -//! Native child-with-parents submission. RPC package wire compatibility is separate. +//! Package submission through the existing stamped admission and mutation owner. use crate::admission::{ AdmissionFence, MAX_ADMISSION_RETRIES, batch_requests, resolve_mempool_inputs, }; @@ -8,7 +8,45 @@ use crate::{ AdmissionChain, AdmissionOrigin, LocalOrigin, MempoolGateway, MutationResult, SubmitError, }; use alloc::vec::Vec; -use bitcoin_rs_primitives::{Tx, unix_time_secs}; +use bitcoin_rs_primitives::{Tx, Wtxid, unix_time_secs}; + +/// One submitted transaction's result. Identities follow the submitted order. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PackageTxResult { + /// Accepted or already resident. Resident entries have no effective rate. + Accepted { + /// Sigop-adjusted virtual size. + vsize: u32, + /// Actual transaction fee, without operator deltas. + base_fee: u64, + /// Modified fee rate of the newly accepted subpackage, in sat/kvB. + effective_fee_rate: Option, + /// Witnesses of the subpackage this admission committed with. + effective_includes: Vec, + }, + /// The resident transaction has the same txid and another witness. + DifferentWitness(Wtxid), + /// No membership was committed for this transaction by this attempt. + Rejected(SubmitError), + /// Package-wide validation stopped before this transaction was evaluated. + Unvalidated, +} + +/// Scoped submitpackage results, including independently accepted members. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageSubmission { + /// A structural or combined-policy failure, if any. + pub package_error: Option, + /// One result per submitted transaction, in input order. + pub tx_results: Vec, +} + +struct PackageAttempt { + mutation: MutationResult, + results: Vec, + error: Option, + package_error: Option, +} /// Orders an arbitrary-order child-with-parents package for submission. /// @@ -58,10 +96,147 @@ fn ordered(mut txs: Vec) -> Result, SubmitError> { } impl MempoolGateway { + /// Core's individual-then-aggregate submission for a singleton or 1p1c. + /// Wider packages and replacement are explicitly unsupported. The atomic + /// native operation remains the sole owner of preparation and commit. + #[expect( + clippy::too_many_lines, + reason = "keep Core's individual pass, atomic reconsideration and final membership results in one auditable sequence" + )] + pub fn submit_rpc_package( + &self, + txs: &[Tx], + max_rate: Option, + time: u64, + chain: &dyn AdmissionChain, + ) -> Result { + if txs.len() > 2 { + return Err(AcceptanceRejectReason::PackageUnsupported.into()); + } + let structure_error = crate::package::check_structure(txs).err(); + // Core classifies every unordered pair — duplicates included — as a + // topology violation; run this check before the structural verdicts. + if txs.len() == 2 + && (!txs[1] + .inputs + .iter() + .any(|input| input.previous_output.txid == txs[0].txid()) + || txs[0] + .inputs + .iter() + .any(|input| input.previous_output.txid == txs[1].txid())) + { + return Err(AcceptanceRejectReason::PackageTopology.into()); + } + if let Some(reason) = structure_error { + return Ok(PackageSubmission { + package_error: Some(reason), + tx_results: vec![PackageTxResult::Unvalidated; txs.len()], + }); + } + let mode = AdmissionMode::PackageRpcSubmit(max_rate.filter(|rate| *rate != 0)); + let mut result = PackageSubmission { + package_error: None, + tx_results: Vec::with_capacity(txs.len()), + }; + let mut reconsider = Vec::new(); + let mut quit_early = false; + // Pinned Core AcceptPackage first commits individually admissible + // members. A valid parent survives a child's rejection; a low-fee + // parent remains absent until its paying child passes the atomic plan. + for (index, tx) in txs.iter().enumerate() { + let row = match self.submit_package_attempt( + core::slice::from_ref(tx), + LocalOrigin::Rpc, + max_rate, + |_| time, + chain, + mode, + ) { + Ok(attempt) => attempt + .results + .into_iter() + .next() + .unwrap_or(PackageTxResult::Unvalidated), + Err(error) => PackageTxResult::Rejected(error), + }; + if let PackageTxResult::Rejected(error) = row { + if matches!( + error, + SubmitError::Policy( + AcceptanceRejectReason::MinRelayFeeNotMet + | AcceptanceRejectReason::MissingInputs + ) + ) { + reconsider.push(index); + } else { + quit_early = true; + } + } + result.tx_results.push(row); + } + if !quit_early && reconsider.len() > 1 { + let pending: Vec<_> = reconsider.iter().map(|&index| txs[index].clone()).collect(); + match self.submit_package_attempt( + &pending, + LocalOrigin::Rpc, + max_rate, + |_| time, + chain, + mode, + ) { + Ok(attempt) => { + result.package_error = attempt.package_error; + if attempt.results.iter().any(|row| { + matches!( + row, + PackageTxResult::Rejected(SubmitError::Policy( + AcceptanceRejectReason::MissingEphemeralSpends + )) + ) + }) { + result.package_error = Some(AcceptanceRejectReason::MissingEphemeralSpends); + } + for (index, row) in reconsider.into_iter().zip(attempt.results) { + // Core retains the individual error for unfinished rows. + if row != PackageTxResult::Unvalidated { + result.tx_results[index] = row; + } + } + } + Err(error) => { + for index in reconsider { + result.tx_results[index] = PackageTxResult::Rejected(error); + } + } + } + } + // Concurrent admissions may have evicted an earlier independent + // success. Never report it as currently resident after it disappeared. + let pool = self.read(); + for (tx, row) in txs.iter().zip(&mut result.tx_results) { + if !matches!( + row, + PackageTxResult::Accepted { .. } | PackageTxResult::DifferentWitness(_) + ) { + continue; + } + match pool.entry_by_txid(&tx.txid()) { + Some(entry) if entry.wtxid == tx.wtxid() => {} + Some(entry) => *row = PackageTxResult::DifferentWitness(entry.wtxid), + None => { + *row = PackageTxResult::Rejected(AcceptanceRejectReason::MissingInputs.into()); + } + } + } + Ok(result) + } + /// Atomically submits a native child-with-independent-parents package, /// accepting arbitrary input order. No replacement or capacity eviction; /// new members pay the aggregate relay/pressure floor. Resident and - /// positively confirmed members contribute no fees. RPC/REST is unchanged. + /// positively confirmed members contribute no fees. The RPC instead uses + /// `submit_rpc_package`'s individual-first result contract. pub fn submit_local_package( &self, txs: Vec, @@ -74,25 +249,48 @@ impl MempoolGateway { self.submit_package_with_times(&txs, origin, max_rate, |_| time, chain) } + pub(crate) fn submit_package_with_times( + &self, + txs: &[Tx], + origin: LocalOrigin, + max_rate: Option, + time: impl Fn(&Tx) -> u64, + chain: &dyn AdmissionChain, + ) -> Result { + let attempt = self.submit_package_attempt( + txs, + origin, + max_rate, + time, + chain, + AdmissionMode::PackageSubmit, + )?; + if let Some(error) = attempt.error { + return Err(error); + } + Ok(attempt.mutation) + } + #[expect( clippy::too_many_lines, - reason = "keep package capture, verification, stamp revalidation and atomic commit in one auditable owner" + reason = "one stamped package preparation/commit owner serves native and RPC submission" )] - pub(crate) fn submit_package_with_times( + fn submit_package_attempt( &self, txs: &[Tx], origin: LocalOrigin, max_rate: Option, time: impl Fn(&Tx) -> u64, chain: &dyn AdmissionChain, - ) -> Result { + mode: AdmissionMode, + ) -> Result { crate::package::check_structure(txs)?; let origin = AdmissionOrigin::from(origin); 'attempt: for _ in 0..MAX_ADMISSION_RETRIES { let Some(generation) = self.stable_generation() else { continue; }; - let (pending, sequence, stamp, inputs) = { + let (pending, sequence, stamp, inputs, mut results) = { let pool = self.read(); let pending: Vec<_> = txs .iter() @@ -112,14 +310,39 @@ impl MempoolGateway { { continue; } + let results = txs + .iter() + .map(|tx| { + pool.entry_by_txid(&tx.txid()).map_or( + PackageTxResult::Unvalidated, + |entry| { + if entry.wtxid == tx.wtxid() { + PackageTxResult::Accepted { + vsize: entry.vsize, + base_fee: entry.fee, + effective_fee_rate: None, + effective_includes: Vec::new(), + } + } else { + PackageTxResult::DifferentWitness(entry.wtxid) + } + }, + ) + }) + .collect::>(); if pending.is_empty() { - return Ok(MutationResult::empty()); + return Ok(PackageAttempt { + mutation: MutationResult::empty(), + results, + error: None, + package_error: None, + }); } let inputs = pending .iter() .map(|tx| resolve_mempool_inputs(&pool, tx)) .collect(); - (pending, sequence, pool.policy_stamp(), inputs) + (pending, sequence, pool.policy_stamp(), inputs, results) }; let Some(mut requests) = batch_requests( &pending, @@ -129,7 +352,7 @@ impl MempoolGateway { 0, origin, (generation, sequence), - AdmissionMode::PackageSubmit, + mode, ) else { continue; }; @@ -142,7 +365,7 @@ impl MempoolGateway { .iter() .map(|request| (*request.tx).clone()) .collect(); - let (mut prepared, checks, plan) = { + let (mut prepared, checks, plan, entry_fees) = { let pool = self.read(); if self .check_admission_state( @@ -157,7 +380,12 @@ impl MempoolGateway { continue; } if pending.is_empty() { - return Ok(MutationResult::empty()); + return Ok(PackageAttempt { + mutation: MutationResult::empty(), + results, + error: None, + package_error: None, + }); } let prepared: Vec<_> = requests .iter() @@ -165,18 +393,30 @@ impl MempoolGateway { Self::prepare_admission( &pool, request, - AdmissionMode::PackageSubmit, + mode, AdmissionFence::Stable, self.engine(), ) }) .collect(); let entries = crate::package::package_entries(&requests, &prepared); + // Modified fees decide effective rates; base fees miss the + // deltas the pool applies (standardness.rs). A failed lookup + // nulls the rates rather than reporting a base-fee quote. + let entry_fees: Option> = entries + .iter() + .map(|entry| { + pool.modified_fee_for(entry.txid, entry.fee) + .ok() + .map(|fee| (entry.txid, fee, entry.vsize)) + }) + .collect(); let checks = crate::package::capture_package_checks(&pool, &pending, &entries); ( prepared, checks, pool.prepare_package(entries, unix_time_secs()), + entry_fees, ) }; // Unlike a single preview, even one new package member must use @@ -188,7 +428,7 @@ impl MempoolGateway { &mut prepared, Some(checks), aggregate_error, - AdmissionMode::PackageSubmit, + mode, self.script_cache(), ); let verdict = facts @@ -212,6 +452,40 @@ impl MempoolGateway { .iter() .filter_map(|job| job.script_cache_key) .collect(); + // One atomic commit enters the pool as a single subpackage: Core + // reports every committed member at the package's aggregate rate + // and lists the whole package in `effective-includes`. + let effective_rate = entry_fees.as_ref().and_then(|fees| { + let (fee, size): (i128, u64) = + fees.iter() + .fold((0, 0), |(fee, size), (_, member_fee, vsize)| { + (fee + member_fee, size + u64::from(*vsize)) + }); + fee.max(0) + .checked_mul(1_000) + .and_then(|total| (size != 0).then(|| total / i128::from(size))) + .and_then(|rate| u64::try_from(rate).ok()) + }); + let includes: Vec<_> = pending.iter().map(Tx::wtxid).collect(); + for ((tx, row), job) in pending.iter().zip(&facts.results).zip(&prepared) { + if let Some(index) = txs.iter().position(|original| original.txid() == tx.txid()) { + results[index] = if let Some(reason) = row.reject_reason { + PackageTxResult::Rejected(match job.rejection { + Some((crate::AdmitError::Consensus, _)) => SubmitError::Consensus, + _ => SubmitError::Policy(reason), + }) + } else if verdict.is_none() { + PackageTxResult::Accepted { + vsize: row.vsize, + base_fee: row.base_fee.unwrap_or(0), + effective_fee_rate: effective_rate, + effective_includes: includes.clone(), + } + } else { + PackageTxResult::Unvalidated + }; + } + } let result = self.commit( origin, |pool| { @@ -245,13 +519,41 @@ impl MempoolGateway { // must not fail the package over them. let admitted: Vec<_> = pending.iter().map(Tx::txid).collect(); self.check_admitted_residency(origin, AdmissionFence::Stable, &admitted)?; - return Ok(result); + return Ok(PackageAttempt { + mutation: result, + results, + error: None, + package_error: None, + }); } Err(crate::AdmitError::GenerationChanged | crate::AdmitError::MempoolChanged) => { continue 'attempt; } - Err(crate::AdmitError::Policy(reason)) => return Err(reason.into()), - Err(crate::AdmitError::Consensus) => return Err(SubmitError::Consensus), + Err(error) => { + let error = match error { + crate::AdmitError::Policy(reason) => SubmitError::Policy(reason), + _ => SubmitError::Consensus, + }; + // A package floor failure is attributed to the paying child. + if facts.package_error == Some(AcceptanceRejectReason::MinRelayFeeNotMet) { + if let Some(index) = txs.iter().position(|tx| { + pending.last().is_some_and(|last| last.txid() == tx.txid()) + }) { + results[index] = PackageTxResult::Rejected(error); + } + } + // A singleton always has an individual refusal, even when + // the atomic package planner supplies the reason. + if txs.len() == 1 { + results[0] = PackageTxResult::Rejected(error); + } + return Ok(PackageAttempt { + mutation: MutationResult::empty(), + results, + error: Some(error), + package_error: facts.package_error, + }); + } } } Err(SubmitError::RetryExhausted) diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs index 96c0d1b66..8aff62f9d 100644 --- a/crates/node/src/config.rs +++ b/crates/node/src/config.rs @@ -166,6 +166,8 @@ pub struct RpcConfig { pub rest: bool, /// Optional experimental keyless BIP352 read service, off by default. pub silent_payments: bool, + /// Optional BIP337 compressed-transaction RPCs, off by default. + pub compressed_transactions: bool, pub auth: Auth, } @@ -321,6 +323,7 @@ impl NodeConfig { public_max_connections: settings.rpc.public_max_connections.unwrap_or(128), rest: settings.rpc.rest.unwrap_or(false), silent_payments: settings.rpc.silent_payments.unwrap_or(false), + compressed_transactions: settings.rpc.compressed_transactions.unwrap_or(false), auth: rpc_auth(settings), }, indexes: IndexConfig { diff --git a/crates/node/src/lifecycle.rs b/crates/node/src/lifecycle.rs index a62e623cb..27c3c628d 100644 --- a/crates/node/src/lifecycle.rs +++ b/crates/node/src/lifecycle.rs @@ -188,6 +188,7 @@ fn bind_rpc( let chainstate = state.chainstate(); let context = Context::from_handles(ContextHandles { silent_payments: config.rpc.silent_payments, + compressed_transactions: config.rpc.compressed_transactions, chain: ChainHandles { chain_tip: chainstate.header_tip_reader(), applied_tip: chainstate.applied_tip_reader(), diff --git a/crates/node/src/options.rs b/crates/node/src/options.rs index 637a54cb0..d20216cae 100644 --- a/crates/node/src/options.rs +++ b/crates/node/src/options.rs @@ -697,6 +697,12 @@ macro_rules! option_rows { env["BITCOIN_RS_SILENT_PAYMENTS", parse_bool] toml native("silent_payments") } + /// Enable the optional BIP337 compressrawtransaction and decompressrawtransaction RPCs. + compressed_transactions as compressed_transactions: Option { + cli[#[arg(long = "compressed-transactions", num_args = 0..=1, default_missing_value = "true")]] + env["BITCOIN_RS_COMPRESSED_TRANSACTIONS", parse_bool] + toml native("compressed_transactions") + } /// JSON-RPC bind address. bind as rpc_bind: Option { cli[#[arg(long = "rpc-bind")]] diff --git a/crates/node/tests/config_layered.rs b/crates/node/tests/config_layered.rs index 7f1d2ce32..f90e425be 100644 --- a/crates/node/tests/config_layered.rs +++ b/crates/node/tests/config_layered.rs @@ -167,6 +167,28 @@ fn silent_payments_toml_overlay_preserves_explicit_disable() -> Result<()> { Ok(()) } +#[test] +fn compressed_transactions_default_off_and_overlay() -> Result<()> { + assert!( + !NodeConfig::default_for_network(Network::Regtest) + .rpc + .compressed_transactions + ); + let enabled: UserConfig = toml::from_str("compressed_transactions = true")?; + assert!(resolve(&[&enabled])?.rpc.compressed_transactions); + let mut environment = UserConfig::default(); + environment.apply_env( + "BITCOIN_RS_COMPRESSED_TRANSACTIONS", + std::ffi::OsStr::new("false"), + )?; + assert!( + !resolve(&[&enabled, &environment])? + .rpc + .compressed_transactions + ); + Ok(()) +} + #[test] fn standard_network_uses_builtin_defaults() -> Result<()> { let layer = UserConfig { diff --git a/crates/rpc/README.md b/crates/rpc/README.md index 2e68ee69b..a91e02a29 100644 --- a/crates/rpc/README.md +++ b/crates/rpc/README.md @@ -22,9 +22,8 @@ no backend cargo feature (`g17_dependency_direction` proves both from - Other `POST` → JSON-RPC. `Auth::validate_header` (`crates/rpc/src/auth.rs`) guards this path only. - Any other method or GET outside `/rest/`, `/api`, and `/esplora` → 404 at the demux. Unprefixed electrs paths and `HEAD`/`PUT`/`DELETE` do not enter Esplora or JSON-RPC. The request parser accepts those methods so `classify` can 404 them instead of answering JSON-RPC 400. - **JSON-RPC Framing & Protocol Versioning**: `JsonRpcVersion` (`crates/rpc/src/server.rs`) governs wire framing: - - Requests with `"jsonrpc": "2.0"` use JSON-RPC 2.0 (`JsonRpcVersion::V2`): success responses emit `{"jsonrpc":"2.0","result":...,"id":...}` (HTTP 200), error responses emit `{"jsonrpc":"2.0","error":...,"id":...}` (HTTP 200), and requests omitting `id` are treated as notifications returning HTTP 204 No Content. - - Other requests use JSON-RPC 1.1 / legacy (`JsonRpcVersion::Legacy`): success responses emit `{"result":...,"error":null,"id":...}` (HTTP 200), error responses emit `{"result":null,"error":...,"id":...}` with HTTP 500 status, and missing `id` values default to `null`. -- **JSON-RPC Batches**: `handle_decoded_json` (`crates/rpc/src/server.rs`) accepts a top-level JSON array and dispatches each entry independently via `handle_single_json`, preserving each entry's own 1.1/2.0 framing. An empty array is rejected (`InvalidRequest`, "batch must not be empty") with HTTP 500 and a legacy-envelope error body (not an array); Core 31.1 instead answers `[]` with HTTP 200. Replies assemble into a JSON array in request order; 2.0 notifications contribute no entry. A notification-only batch answers HTTP 204 with no body; otherwise the status is the maximum per-entry status. There is no per-entry count limit; the bound is the HTTP body `Content-Length <= MAX_BODY_BYTES` (16 MiB). + - Requests with `"jsonrpc": "2.0"` use JSON-RPC 2.0 (`JsonRpcVersion::V2`): success responses emit `{"jsonrpc":"2.0","result":...,"id":...}` (HTTP 200), dispatch error responses emit `{"jsonrpc":"2.0","error":...,"id":...}` (HTTP 200), and requests omitting `id` are treated as notifications returning HTTP 204 No Content. An envelope refusal (`-32600` raised while the request envelope itself is validated) answers HTTP 400 in either dialect; only the reply envelope follows how far parsing got. + - Other requests use JSON-RPC 1.1 / legacy (`JsonRpcVersion::Legacy`): success responses emit `{"result":...,"error":null,"id":...}` (HTTP 200), error responses emit `{"result":null,"error":...,"id":...}` with Core's status mapping (`-32600` → 400, `-32601` → 404, every other code → 500), and missing `id` values default to `null`. A top-level JSON scalar is `-32700` "Top-level object parse error" at HTTP 500 with a null id. A batch with at least one reply answers HTTP 200; an empty batch answers 200 with `[]`; an all-notification batch answers 204 with no body. - **Wallet-Free Surface**: The node ships no wallet. Methods requiring private keys (funding, signing, key import/export) are not implemented and return `RpcError::MethodNotFound`. PSBT combination and finalization, descriptor utilities, and `scantxoutset` remain available because they are driven by external signers without this process holding private key material. ### 2. Deep module separation and system owners diff --git a/crates/rpc/core-compat.toml b/crates/rpc/core-compat.toml index 5abf0baf3..4d8756aee 100644 --- a/crates/rpc/core-compat.toml +++ b/crates/rpc/core-compat.toml @@ -147,7 +147,7 @@ jar_sha256 = "079b6c2320252469dcf79afec6886b8255d3dd1b34a9484433c88986752efaa8" reference_settings = ["-acceptnonstdtxn=0", "-minrelaytxfee=0.00001000", "-incrementalrelayfee=0.00001000", "-dustrelayfee=0.00003000", "-datacarriersize=83"] process_gate = "overhaul_process_harness::policy_cases" unsupported = [ - "Aggregate CPFP/package RBF submission: submitpackage returns -32601; Esplora /txs/package returns 404.", + "submitpackage supports singletons and 1p1c CPFP only; wider packages, replacement and capacity eviction are explicitly refused. Esplora /txs/package remains 404. Native consensus/script and numeric rejection details remain deviations.", "Core transient non-optimal SFL diagrams, its randomized work-budget decisions and tie ordering are not emulated by the exact closure solver; adversarial/churn parity remains unverified.", "Core allocator-based capacity is not implemented; pool capacity, the rolling minimum fee, and post-block decay are measured against pool total virtual size, while each eviction trim bump derives from the removed chunk's own modified fee and charged weight (rbf::trim_bump).", "Core speculative insert-and-trim failure side effects are not reproduced; candidate refusal is atomic.", diff --git a/crates/rpc/src/compressed_tx.rs b/crates/rpc/src/compressed_tx.rs new file mode 100644 index 000000000..bc6316b0f --- /dev/null +++ b/crates/rpc/src/compressed_tx.rs @@ -0,0 +1,1793 @@ +//! BIP337 compressed-transaction codec (`compressrawtransaction` / +//! `decompressrawtransaction`). +//! +//! One pure codec shared by the two RPC adapters. It owns no chain, +//! mempool, or storage state: every contextual fact (prevout scripts and +//! values, block-height locations, flat output indices) arrives through +//! [`PrevoutResolver`], and the RPC layer supplies the single production +//! resolver. Removing this module and its two registry rows leaves +//! consensus, relay, mempool, storage, REST, and Esplora untouched. +//! +//! # Recorded spec decisions +//! +//! The published BIP337 prose and its reference implementation disagree +//! in five places. This codec follows the reference behaviour, which is +//! the only reading that restores the published test vectors +//! byte-for-byte: +//! +//! 1. Every variable-length integer except CompactSize fields is the +//! carry-aware Core VARINT (the chainstate integer form), not the +//! 7-bit little-endian form the prose describes: the amount `5000` +//! encodes as `a608`, which only the Core form produces. +//! 2. The six-bit per-input metadata value needs the public-key-hash bit +//! the prose table omits: `m = sig | pkh<<1 | std<<2 | seq<<3 | +//! outpoint<<5`. Without that row the P2SH-P2WPKH vector's metadata +//! byte cannot be reproduced. +//! 3. Compressed signatures cover the vector-proven closed set (P2TR key +//! path, P2PKH, P2WPKH, P2SH-P2WPKH). Everything else — P2TR script +//! path, P2WSH, multisig, P2PK, nonstandard scripts — uses the +//! uncompressed envelope: VARINT scriptSig length and bytes, then a +//! VARINT witness item count with VARINT length-prefixed items. +//! 4. Minimum height follows the reference rule, not the prose: start at +//! `locktime - 1` when the locktime is nonzero, take the minimum with +//! each compressible `coin.height - 1`, and encode the `u32::MAX` +//! "no candidate" sentinel as zero (presence bit clear). Offsets and +//! the stored locktime are checked subtractions from that minimum. +//! 5. A compressed outpoint is emitted only for a prevout with a +//! confirmed height and a resolvable flat output index. Anything else +//! degrades to the raw outpoint plus one `UTXO(i): ` warning; +//! an unknown outpoint is never fabricated. + +use bitcoin::hashes::{Hash as _, hash160}; +use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; +use bitcoin::secp256k1::{Message, Secp256k1}; +use bitcoin_rs_primitives::{ + Amount, LockTime, OutPoint, Script, Sequence, SighashCache, Tx, TxIn, TxOut, Txid, Witness, + consensus_bytes, varint, +}; +use hashbrown::HashMap; + +use crate::error::RpcError; + +/// Largest compressed (or reconstructed) transaction, in bytes. +pub(crate) const MAX_COMPRESSED_TX_BYTES: usize = 4_000_000; +/// Largest input count accepted before any allocation is sized by it. +pub(crate) const MAX_COMPRESSED_INPUTS: usize = 100_000; +/// Largest output count accepted before any allocation is sized by it. +pub(crate) const MAX_COMPRESSED_OUTPUTS: usize = 100_000; +/// Total block-body bytes one resolver call may retain. +pub(crate) const MAX_RETAINED_BODY_BYTES: usize = 16 * 1024 * 1024; +/// Bodies one resolver call may retain (one largest body per slot). +pub(crate) const MAX_RETAINED_BODIES: usize = MAX_RETAINED_BODY_BYTES / MAX_COMPRESSED_TX_BYTES; +/// Total flat-index bytes one resolver call may retain. +pub(crate) const MAX_RETAINED_INDEX_BYTES: usize = 12 * 1024 * 1024; +/// Combined retained body plus index budget for one resolver call. +pub(crate) const MAX_RETAINED_BYTES_TOTAL: usize = 32 * 1024 * 1024; +/// Memoized resolver state (prevout memo entries) budget for one call. +pub(crate) const MAX_RESOLVER_STATE_BYTES: usize = 8 * 1024 * 1024; +/// Cumulative body loads (distinct reads plus eviction reloads) one +/// resolver call may perform. Bounds the total body-read/parse/materialize/ +/// txid work under the stable-chainstate exclusion (snapshot isolation: one +/// uninterrupted chainstate read) even when retained budgets stay satisfied +/// through eviction; exceeding it fails closed. Sized at twice the retained +/// slots so a grouped pass over a handful of heights succeeds while a +/// cycling pass cannot reload forever. +pub(crate) const MAX_CUMULATIVE_BODY_LOADS: usize = MAX_RETAINED_BODIES * 2; +/// Cumulative body bytes one resolver call may read, bounding total parse +/// work under the exclusion to twice the retained-body ceiling. +pub(crate) const MAX_CUMULATIVE_BODY_BYTES: usize = MAX_RETAINED_BODY_BYTES * 2; + +/// Cancellation is polled once per this many codec inputs. +const CANCELLATION_POLL_INPUTS: usize = 256; + +/// Caller-selected compression behaviour. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct CompressOptions { + /// Replace resolvable outpoints with height/flat-index references. + pub compress_outpoints: bool, +} + +/// One prevout as resolution proved it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResolvedPrevout { + /// The spent output itself (script and value; BIP143 recovery commits + /// to the value, so it is mandatory context, never optional). + pub txout: TxOut, + /// Height of the block confirming the prevout, when it is confirmed. + pub confirmed_height: Option, +} + +/// Which resolver budget a [`LookupError::LimitExceeded`] hit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Limit { + /// Too many retained bodies. + Bodies, + /// Retained body bytes. + BodyBytes, + /// Cumulative body loads (distinct reads plus eviction reloads). + Loads, + /// Cumulative body bytes read. + LoadBytes, + /// Retained flat-index bytes. + IndexBytes, + /// Memoized resolver state bytes. + State, +} + +/// Failure to resolve contextual facts for the codec. +/// +/// `Ok(None)` from a resolver method means "proved absent"; the error +/// variants mean "could not prove", and decompress refuses on them +/// rather than fabricating a prevout. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub(crate) enum LookupError { + /// The resolver has no route to the requested fact. + #[error("prevout context is unavailable")] + Missing, + /// No body is available at this height. + #[error("block body at height {0} is unavailable")] + BodyMissing(u32), + /// The body at this height does not parse. + #[error("block body at height {0} is corrupt")] + CorruptBody(u32), + /// The body at this height could not be read. + #[error("block body at height {0} could not be read")] + BodyReadFailed(u32), + /// A resolver budget ran out; the call may be retried. + #[error("compressed transaction resource limit exceeded")] + LimitExceeded(Limit), + /// The caller cancelled while resolving. + #[error("compressed transaction request cancelled")] + Cancelled, +} + +/// Context source for one compress or decompress call. +/// +/// Implementations may retain bounded per-call state (see the +/// `MAX_RETAINED_*` and `MAX_CUMULATIVE_*` ceilings); `&mut self` carries +/// it, so no interior mutability or second cache owner exists. +/// +/// `flat_output_index` (cumulative outputs before `txid` in `height`, +/// plus `vout`) and `outpoint_at` must be exact inverses on one block; +/// the compressed form stores only the flat index. +pub(crate) trait PrevoutResolver { + /// Resolves one spent outpoint, or `Ok(None)` when proved absent. + fn prevout(&mut self, outpoint: &OutPoint) -> Result, LookupError>; + /// Flat output index of `txid`'s output `vout` inside `height`. + fn flat_output_index( + &mut self, + height: u32, + txid: &Txid, + vout: u32, + ) -> Result, LookupError>; + /// Inverse of [`Self::flat_output_index`]: the outpoint and prevout + /// at flat output position `flat_index` inside `height`. + fn outpoint_at( + &mut self, + height: u32, + flat_index: u32, + ) -> Result, LookupError>; + /// Cooperative cancellation, polled by the codec. + fn cancelled(&self) -> bool; +} + +/// Codec failure. Each variant maps to exactly one [`RpcError`]: +/// structural malformation is `-22`, oversize input or output is `-8`, +/// budget exhaustion and cancellation are retryable `-1`, +/// a proved-absent prevout during decompress is `-5`. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub(crate) enum CodecError { + /// The byte stream ended before the transaction was complete. + #[error("compressed transaction is truncated")] + Truncated, + /// Non-minimal `CompactSize`, set padding bits, or trailing bytes. + #[error("compressed transaction is not canonically encoded")] + NonCanonical, + /// A value (varint, count, height arithmetic, total size) overflowed + /// its supported range. + #[error("compressed transaction value exceeds its supported range")] + Overflow, + /// A resolver budget ran out. + #[error("compressed transaction resource limit exceeded")] + LimitExceeded(Limit), + /// A transaction must carry at least one input and one output. + #[error("compressed transaction has no inputs or outputs")] + EmptyTransaction, + /// A compressed signature names a spend type outside the closed set. + #[error("compressed transaction spends an unsupported script type")] + UnsupportedScript, + /// The sighash needed for public-key recovery could not be computed. + #[error("failed to compute sighash for compressed signature recovery")] + Sighash, + /// Decompression needs this prevout and it is proved absent. + #[error("prevout is unavailable for decompression")] + PrevoutMissing(OutPoint), + /// No body is available at this height. + #[error("block body at height {0} is unavailable")] + BodyMissing(u32), + /// The body at this height does not parse. + #[error("block body at height {0} is corrupt")] + CorruptBody(u32), + /// The body at this height could not be read. + #[error("block body at height {0} could not be read")] + BodyReadFailed(u32), + /// No recovery id reproduces a key matching the prevout commitment. + #[error("compressed signature could not be recovered")] + SignatureUnrecoverable, + /// The caller cancelled mid-codec. + #[error("compressed transaction request cancelled")] + Cancelled, +} + +impl From for RpcError { + fn from(error: CodecError) -> Self { + match error { + CodecError::Truncated + | CodecError::NonCanonical + | CodecError::EmptyTransaction + | CodecError::UnsupportedScript + | CodecError::Sighash + | CodecError::SignatureUnrecoverable => Self::Deserialization(error.to_string()), + CodecError::Overflow => Self::InvalidParameter(error.to_string()), + CodecError::LimitExceeded(_) | CodecError::Cancelled => Self::Misc(error.to_string()), + CodecError::PrevoutMissing(_) => Self::NotFound("prevout not found"), + CodecError::BodyMissing(height) => Self::Misc(format!( + "block body at height {height} is unavailable; retry when history is present" + )), + CodecError::CorruptBody(height) => { + Self::Misc(format!("block body at height {height} is corrupt")) + } + CodecError::BodyReadFailed(height) => Self::Misc(format!( + "block body at height {height} could not be read; retry" + )), + } + } +} + +/// Maps a resolver failure onto the codec taxonomy; `missing` is the +/// call-site-specific meaning of [`LookupError::Missing`]. +fn lookup_failure(error: LookupError, missing: CodecError) -> CodecError { + match error { + LookupError::Missing => missing, + LookupError::BodyMissing(height) => CodecError::BodyMissing(height), + LookupError::CorruptBody(height) => CodecError::CorruptBody(height), + LookupError::BodyReadFailed(height) => CodecError::BodyReadFailed(height), + LookupError::LimitExceeded(limit) => CodecError::LimitExceeded(limit), + LookupError::Cancelled => CodecError::Cancelled, + } +} + +// --------------------------------------------------------------------- +// Integer primitives +// --------------------------------------------------------------------- + +/// Encodes the carry-aware Core VARINT (chainstate form). At most ten +/// bytes, so every `u64` amount has a spelling; larger spellings are +/// impossible on this domain. `u32`-range values keep their short (at most +/// five-byte) spelling, which the capped readers below rely on. +fn write_varint(value: u64, out: &mut Vec) -> Result<(), CodecError> { + let mut tmp = [0_u8; 10]; + let mut len = 0_usize; + let mut current = value; + loop { + let low = u8::try_from(current & 0x7f).map_err(|_| CodecError::Overflow)?; + tmp[len] = if len == 0 { low } else { low | 0x80 }; + len += 1; + if current <= 0x7f { + break; + } + current = (current >> 7) - 1; + } + if len > 10 { + return Err(CodecError::Overflow); + } + for index in (0..len).rev() { + out.push(tmp[index]); + } + Ok(()) +} + +/// Bounded byte sink: every write is charged against +/// [`MAX_COMPRESSED_TX_BYTES`] before it lands, so neither the encoded +/// nor the reconstructed buffer can grow past the ceiling. +struct Writer { + bytes: Vec, +} + +impl Writer { + fn new() -> Self { + Self { bytes: Vec::new() } + } + + fn write(&mut self, data: &[u8]) -> Result<(), CodecError> { + let total = self + .bytes + .len() + .checked_add(data.len()) + .ok_or(CodecError::Overflow)?; + if total > MAX_COMPRESSED_TX_BYTES { + return Err(CodecError::Overflow); + } + self.bytes.extend_from_slice(data); + Ok(()) + } + + fn write_u8(&mut self, byte: u8) -> Result<(), CodecError> { + self.write(&[byte]) + } + + fn write_compact_size(&mut self, value: u64) -> Result<(), CodecError> { + self.write(&varint::encode(value)) + } + + fn write_varint(&mut self, value: u64) -> Result<(), CodecError> { + let mut encoded = Vec::new(); + write_varint(value, &mut encoded)?; + self.write(&encoded) + } + + /// VARINT length-prefixed bytestream (VLP). + fn write_vlp(&mut self, data: &[u8]) -> Result<(), CodecError> { + self.write_varint(u64::try_from(data.len()).map_err(|_| CodecError::Overflow)?)?; + self.write(data) + } +} + +/// Bounds-checked cursor over one compressed transaction. +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, pos: 0 } + } + + fn remaining(&self) -> usize { + self.bytes.len() - self.pos + } + + fn take(&mut self, len: usize) -> Result<&'a [u8], CodecError> { + let end = self.pos.checked_add(len).ok_or(CodecError::Overflow)?; + let slice = self.bytes.get(self.pos..end).ok_or(CodecError::Truncated)?; + self.pos = end; + Ok(slice) + } + + fn read_u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn read_compact_size(&mut self) -> Result { + let (value, consumed) = + varint::decode(&self.bytes[self.pos..]).map_err(|error| match error { + varint::VarintError::Truncated { .. } => CodecError::Truncated, + varint::VarintError::NonCanonical { .. } => CodecError::NonCanonical, + })?; + self.pos += consumed; + Ok(value) + } + + /// Reads the carry-aware Core VARINT, up to ten bytes so every `u64` + /// amount decodes. The encoding is a bijection, so the re-encode + /// comparison is the non-minimal-form rejection; overlong spellings of + /// small values stay [`CodecError::NonCanonical`]. + fn read_varint(&mut self) -> Result { + self.read_varint_capped(10) + } + + /// Reads the carry-aware Core VARINT with at most `max_bytes` bytes. + /// `u32`-capped fields (counts, heights, offsets, sequence deltas) use + /// five: their canonical spellings never exceed it, so anything longer + /// is [`CodecError::Overflow`] even before the range check. + fn read_varint_capped(&mut self, max_bytes: usize) -> Result { + let start = self.pos; + let mut value = 0_u64; + for _ in 0..max_bytes { + let byte = self.read_u8()?; + value = value + .checked_mul(128) + .and_then(|v| v.checked_add(u64::from(byte & 0x7f))) + .ok_or(CodecError::Overflow)?; + if byte & 0x80 == 0 { + let mut canonical = Vec::new(); + write_varint(value, &mut canonical)?; + if canonical != self.bytes[start..self.pos] { + return Err(CodecError::NonCanonical); + } + return Ok(value); + } + value = value.checked_add(1).ok_or(CodecError::Overflow)?; + } + Err(CodecError::Overflow) + } + + fn read_vlp(&mut self) -> Result<&'a [u8], CodecError> { + // Length prefixes stay five-byte capped: script and witness items + // are bounded by [`MAX_COMPRESSED_TX_BYTES`] well below it. + let len = self.read_varint_capped(5)?; + let len = usize::try_from(len).map_err(|_| CodecError::Overflow)?; + self.take(len) + } +} + +fn read_u32_varint(reader: &mut Reader<'_>) -> Result { + u32::try_from(reader.read_varint_capped(5)?).map_err(|_| CodecError::Overflow) +} + +fn read_u32_compact_size(reader: &mut Reader<'_>) -> Result { + u32::try_from(reader.read_compact_size()?).map_err(|_| CodecError::Overflow) +} + +// --------------------------------------------------------------------- +// Bit packing (input/output metadata) +// --------------------------------------------------------------------- + +/// Packs each 6-bit input value followed by each 3-bit output value, +/// high-field-first, zero-padded to a whole byte. Input `37` +/// (`outpoint=1, seq=0, std=1, pkh=0, sig=1`) plus output `5` is the +/// byte pair `96 80`. +fn encode_bits(inputs: &[u8], outputs: &[u8]) -> Vec { + let total_bits = inputs.len() * 6 + outputs.len() * 3; + let mut packed = vec![0_u8; total_bits.div_ceil(8)]; + let mut bit_pos = 0_usize; + let push = |value: u8, width: usize, packed: &mut Vec, bit_pos: &mut usize| { + for shift in (0..width).rev() { + if (value >> shift) & 1 == 1 { + packed[*bit_pos / 8] |= 0x80 >> (*bit_pos % 8); + } + *bit_pos += 1; + } + }; + for value in inputs { + push(*value, 6, &mut packed, &mut bit_pos); + } + for value in outputs { + push(*value, 3, &mut packed, &mut bit_pos); + } + packed +} + +/// Inverse of [`encode_bits`]; set padding bits are non-canonical. +fn decode_bits( + reader: &mut Reader<'_>, + inputs: usize, + outputs: usize, +) -> Result<(Vec, Vec), CodecError> { + let total_bits = inputs * 6 + outputs * 3; + let byte_len = total_bits.div_ceil(8); + let packed = reader.take(byte_len)?; + let mut bit_pos = 0_usize; + let take_bits = |width: usize, packed: &[u8], bit_pos: &mut usize| -> u8 { + let mut value = 0_u8; + for _ in 0..width { + let bit = (packed[*bit_pos / 8] >> (7 - (*bit_pos % 8))) & 1; + value = (value << 1) | bit; + *bit_pos += 1; + } + value + }; + let mut input_meta = Vec::with_capacity(inputs); + for _ in 0..inputs { + input_meta.push(take_bits(6, packed, &mut bit_pos)); + } + let mut output_meta = Vec::with_capacity(outputs); + for _ in 0..outputs { + output_meta.push(take_bits(3, packed, &mut bit_pos)); + } + for index in total_bits..byte_len * 8 { + if (packed[index / 8] >> (7 - (index % 8))) & 1 == 1 { + return Err(CodecError::NonCanonical); + } + } + Ok((input_meta, output_meta)) +} + +// --------------------------------------------------------------------- +// Script shapes +// --------------------------------------------------------------------- + +/// Output script classes, in metadata-code order (custom is 0). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OutputShape { + Custom, + P2pkUncompressed, + P2pkCompressed, + P2pkh, + P2sh, + P2wpkh, + P2wsh, + P2tr, +} + +fn output_shape(script: &[u8]) -> (OutputShape, &[u8]) { + if script.len() == 67 && script[0] == 0x41 && script[66] == 0xac { + return (OutputShape::P2pkUncompressed, &script[1..66]); + } + if script.len() == 35 && script[0] == 0x21 && script[34] == 0xac { + return (OutputShape::P2pkCompressed, &script[1..34]); + } + if script.len() == 25 + && script[0] == 0x76 + && script[1] == 0xa9 + && script[2] == 0x14 + && script[23] == 0x88 + && script[24] == 0xac + { + return (OutputShape::P2pkh, &script[3..23]); + } + if script.len() == 23 && script[0] == 0xa9 && script[1] == 0x14 && script[22] == 0x87 { + return (OutputShape::P2sh, &script[2..22]); + } + if script.len() == 22 && script[0] == 0x00 && script[1] == 0x14 { + return (OutputShape::P2wpkh, &script[2..22]); + } + if script.len() == 34 && script[0] == 0x00 && script[1] == 0x20 { + return (OutputShape::P2wsh, &script[2..34]); + } + if script.len() == 34 && script[0] == 0x51 && script[1] == 0x20 { + return (OutputShape::P2tr, &script[2..34]); + } + (OutputShape::Custom, script) +} + +fn output_code(shape: OutputShape) -> u8 { + match shape { + OutputShape::Custom => 0, + OutputShape::P2pkUncompressed => 1, + OutputShape::P2pkCompressed => 2, + OutputShape::P2pkh => 3, + OutputShape::P2sh => 4, + OutputShape::P2wpkh => 5, + OutputShape::P2wsh => 6, + OutputShape::P2tr => 7, + } +} + +fn output_script(code: u8, payload: &[u8]) -> Result, CodecError> { + let script = match (code, payload.len()) { + (1, 65) => { + let mut script = Vec::with_capacity(67); + script.push(0x41); + script.extend_from_slice(payload); + script.push(0xac); + script + } + (2, 33) => { + let mut script = Vec::with_capacity(35); + script.push(0x21); + script.extend_from_slice(payload); + script.push(0xac); + script + } + (3, 20) => { + let mut script = Vec::with_capacity(25); + script.extend_from_slice(&[0x76, 0xa9, 0x14]); + script.extend_from_slice(payload); + script.extend_from_slice(&[0x88, 0xac]); + script + } + (4, 20) => { + let mut script = Vec::with_capacity(23); + script.extend_from_slice(&[0xa9, 0x14]); + script.extend_from_slice(payload); + script.push(0x87); + script + } + (5, 20) => { + let mut script = Vec::with_capacity(22); + script.extend_from_slice(&[0x00, 0x14]); + script.extend_from_slice(payload); + script + } + (6, 32) => { + let mut script = Vec::with_capacity(34); + script.extend_from_slice(&[0x00, 0x20]); + script.extend_from_slice(payload); + script + } + (7, 32) => { + let mut script = Vec::with_capacity(34); + script.extend_from_slice(&[0x51, 0x20]); + script.extend_from_slice(payload); + script + } + _ => return Err(CodecError::NonCanonical), + }; + Ok(script) +} + +/// Spend-side prevout classes relevant to signature compression. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PrevoutKind { + P2pkh([u8; 20]), + P2sh([u8; 20]), + P2wpkh([u8; 20]), + P2wsh, + P2tr, + Other, +} + +fn prevout_kind(script: &[u8]) -> PrevoutKind { + match output_shape(script) { + (OutputShape::P2pkh, payload) => { + PrevoutKind::P2pkh(payload.try_into().unwrap_or([0_u8; 20])) + } + (OutputShape::P2sh, payload) => PrevoutKind::P2sh(payload.try_into().unwrap_or([0_u8; 20])), + (OutputShape::P2wpkh, payload) => { + PrevoutKind::P2wpkh(payload.try_into().unwrap_or([0_u8; 20])) + } + (OutputShape::P2wsh, _) => PrevoutKind::P2wsh, + (OutputShape::P2tr, _) => PrevoutKind::P2tr, + _ => PrevoutKind::Other, + } +} + +fn hash160_20(data: &[u8]) -> [u8; 20] { + *hash160::Hash::hash(data).as_byte_array() +} + +/// One script push (`data` only), canonical direct/PUSHDATA1/PUSHDATA2. +fn push_data(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + 3); + match data.len() { + len if len < 76 => { + out.push(u8::try_from(len).unwrap_or(0)); + } + len if len <= 0xff => { + out.push(0x4c); + out.push(u8::try_from(len).unwrap_or(0)); + } + len => { + out.push(0x4d); + let len = u16::try_from(len).unwrap_or(u16::MAX); + out.extend_from_slice(&len.to_le_bytes()); + } + } + out.extend_from_slice(data); + out +} + +/// Reads one push at `pos`, returning `(payload, next_pos)`. +fn read_push(script: &[u8], pos: usize) -> Option<(&[u8], usize)> { + let opcode = *script.get(pos)?; + let (len, header) = match opcode { + 0 => (0_usize, 1_usize), + 1..=75 => (usize::from(opcode), 1_usize), + 0x4c => (usize::from(*script.get(pos + 1)?), 2_usize), + 0x4d => ( + usize::from(u16::from_le_bytes([ + *script.get(pos + 1)?, + *script.get(pos + 2)?, + ])), + 3_usize, + ), + 0x4e => ( + usize::try_from(u32::from_le_bytes([ + *script.get(pos + 1)?, + *script.get(pos + 2)?, + *script.get(pos + 3)?, + *script.get(pos + 4)?, + ])) + .ok()?, + 5_usize, + ), + _ => return None, + }; + let start = pos.checked_add(header)?; + let end = start.checked_add(len)?; + Some((script.get(start..end)?, end)) +} + +/// DER-encodes `r || s` (each 32-byte big-endian) minimally. +fn der_encode(sig64: &[u8; 64]) -> Vec { + fn integer(half: &[u8]) -> Vec { + let mut start = 0_usize; + while start + 1 < half.len() && half[start] == 0 { + start += 1; + } + let trimmed = &half[start..]; + let mut out = Vec::with_capacity(trimmed.len() + 1); + if trimmed.first().is_some_and(|byte| byte & 0x80 != 0) { + out.push(0); + } + out.extend_from_slice(trimmed); + out + } + let r = integer(&sig64[..32]); + let s = integer(&sig64[32..]); + let mut der = Vec::with_capacity(r.len() + s.len() + 6); + der.push(0x30); + der.push(u8::try_from(r.len() + s.len() + 4).unwrap_or(0)); + der.push(0x02); + der.push(u8::try_from(r.len()).unwrap_or(0)); + der.extend_from_slice(&r); + der.push(0x02); + der.push(u8::try_from(s.len()).unwrap_or(0)); + der.extend_from_slice(&s); + der +} + +// --------------------------------------------------------------------- +// Signature forms +// --------------------------------------------------------------------- + +/// A signature in its 64-byte compressed form. +struct CompressedSig { + /// ECDSA `r || s`, or the 64-byte Schnorr signature for P2TR. + sig: [u8; 64], + /// Redeem-script pubkey hash; only P2SH-P2WPKH carries it. + pubkey_hash: Option<[u8; 20]>, + /// Non-standard hash type byte, present exactly when `standard` is + /// false. + hash_type: Option, + /// Standard hash type: `0x01` ECDSA, `0x00` (absent byte) taproot. + standard: bool, +} + +fn ecdsa_compressed(sig_item: &[u8]) -> Option { + let (der, hash_type) = sig_item.split_at(sig_item.len().checked_sub(1)?); + let hash_type = *hash_type.first()?; + let signature = Signature::from_der(der).ok()?; + Some(CompressedSig { + sig: signature.serialize_compact(), + pubkey_hash: None, + hash_type: (hash_type != 1).then_some(hash_type), + standard: hash_type == 1, + }) +} + +/// Extracts the compressed form of `input`'s signature when the prevout +/// kind is in the closed compressible set and the authorization has the +/// exact expected shape; `None` selects the uncompressed envelope. +fn compressed_signature(input: &TxIn, kind: &PrevoutKind) -> Option { + match kind { + PrevoutKind::P2tr => { + // Decompression rebuilds an empty scriptSig (see the P2tr fill + // in `decompress_finalize`), so only inputs that already carry + // the exact key-path shape compress; anything else, including a + // non-empty scriptSig, falls back to the uncompressed envelope. + if !input.script_sig.is_empty() || input.witness.len() != 1 { + return None; + } + let item = input.witness.first()?; + match item.len() { + 64 => Some(CompressedSig { + sig: item.as_slice().try_into().ok()?, + pubkey_hash: None, + hash_type: None, + standard: true, + }), + 65 => { + let (sig, hash_type) = item.split_at(64); + Some(CompressedSig { + sig: sig.try_into().ok()?, + pubkey_hash: None, + hash_type: Some(*hash_type.first()?), + standard: false, + }) + } + _ => None, + } + } + PrevoutKind::P2pkh(expected) => { + // Decompression rebuilds an empty witness for this input. + if !input.witness.is_empty() { + return None; + } + let script = input.script_sig.as_bytes(); + let (sig_item, pos) = read_push(script, 0)?; + let (pubkey, end) = read_push(script, pos)?; + if end != script.len() { + return None; + } + // Exact canonical push shape: both pushes must re-encode + // byte-for-byte via `push_data`. Non-minimal PUSHDATA1/2/4 + // spellings carry the same payloads but rebuild to different + // bytes through `push_data` on decompress, so they fall back + // to the uncompressed envelope. + let mut canonical = push_data(sig_item); + canonical.extend_from_slice(&push_data(pubkey)); + if canonical != script { + return None; + } + // Decompression rebuilds the scriptSig from a recovered key, so + // only an exactly round-tripping pubkey may compress: the + // 33-byte compressed form or the 65-byte `0x04` uncompressed + // form hashing to the prevout commitment. Hybrid keys, misc + // pushes, and foreign keys fall back to the uncompressed + // envelope rather than changing the signer on rebuild. + let compressed = pubkey.len() == 33 && (pubkey[0] == 0x02 || pubkey[0] == 0x03); + if !compressed && (pubkey.len() != 65 || pubkey.first() != Some(&0x04)) { + return None; + } + if hash160_20(pubkey) != *expected { + return None; + } + ecdsa_compressed(sig_item) + } + PrevoutKind::P2wpkh(expected) => { + if !input.script_sig.is_empty() || input.witness.len() != 2 { + return None; + } + // Decompression recovers the key committed by the prevout, so + // only that exact key may be dropped from the witness. + let pubkey = input.witness.get(1)?; + if pubkey.len() != 33 || hash160_20(pubkey) != *expected { + return None; + } + ecdsa_compressed(input.witness.first()?) + } + PrevoutKind::P2sh(script_hash) => { + let script_sig = input.script_sig.as_bytes(); + if script_sig.len() != 23 + || script_sig[0] != 0x16 + || script_sig[1] != 0x00 + || script_sig[2] != 0x14 + { + return None; + } + if input.witness.len() != 2 { + return None; + } + let pubkey = input.witness.get(1)?; + if pubkey.len() != 33 { + return None; + } + let redeem_hash: [u8; 20] = script_sig[3..23].try_into().ok()?; + if hash160_20(pubkey) != redeem_hash || hash160_20(&script_sig[1..]) != *script_hash { + return None; + } + let mut compressed = ecdsa_compressed(input.witness.first()?)?; + compressed.pubkey_hash = Some(redeem_hash); + Some(compressed) + } + PrevoutKind::P2wsh | PrevoutKind::Other => None, + } +} + +fn sequence_code(sequence: u32) -> u8 { + match sequence { + 0 => 1, + 0xffff_fffe => 2, + 0xffff_ffff => 3, + _ => 0, + } +} + +fn sequence_value(code: u8, reader: &mut Reader<'_>) -> Result { + match code { + 0 => read_u32_varint(reader), + 1 => Ok(0), + 2 => Ok(0xffff_fffe), + _ => Ok(0xffff_ffff), + } +} + +// --------------------------------------------------------------------- +// Compress +// --------------------------------------------------------------------- + +/// Groups input indices needing flat-index lookups by required height, in +/// first-appearance order. Heights are already known from the resolved +/// prevouts; inputs degraded to raw outpoints or lacking confirmation need +/// no lookup and stay out of every group. +fn flat_lookup_groups( + tx: &Tx, + resolved: &[Option], + degraded: &[Option], +) -> (Vec, HashMap>) { + let mut order: Vec = Vec::new(); + let mut members: HashMap> = HashMap::new(); + for (index, _) in tx.inputs.iter().enumerate() { + if degraded.get(index).and_then(Option::as_ref).is_some() { + continue; + } + let Some(prevout) = resolved.get(index).and_then(Option::as_ref) else { + continue; + }; + let Some(height) = prevout.confirmed_height else { + continue; + }; + if !members.contains_key(&height) { + order.push(height); + } + members.entry(height).or_default().push(index); + } + (order, members) +} + +/// Resolves flat output indices grouped by height so each body is loaded +/// once: one height's lookups run consecutively, keeping every lookup a +/// retained-body hit instead of evicting and reloading the body per input. +/// Cancellation is polled like the per-input passes. +fn resolve_flats_grouped( + tx: &Tx, + resolver: &mut dyn PrevoutResolver, + order: &[u32], + members: &HashMap>, +) -> Result, LookupError>>>, CodecError> { + let mut results: Vec, LookupError>>> = vec![None; tx.inputs.len()]; + let mut polls = 0_usize; + for &height in order { + let Some(indices) = members.get(&height) else { + continue; + }; + for &index in indices { + if polls % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + return Err(CodecError::Cancelled); + } + polls += 1; + let input = &tx.inputs[index]; + results[index] = Some(resolver.flat_output_index( + height, + &input.previous_output.txid, + input.previous_output.vout, + )); + } + } + Ok(results) +} + +/// Per-input encoding plan, computed after prevout resolution. +struct Plan { + outpoint_compressed: bool, + height_offset: u64, + flat_index: u64, + signature: Option, + sequence: u32, +} + +/// Compresses `tx`. Missing contextual facts degrade a single input to +/// its raw outpoint with one `UTXO(i): ` warning; retained +/// budget exhaustion and cancellation are errors, never downgrades. +/// +/// # Errors +/// Returns [`CodecError`] for empty or oversize transactions, invalid +/// resolver budgets, and cancellation. +#[allow( + clippy::too_many_lines, + reason = "one sequential encoding pass over the transaction fields" +)] +pub(crate) fn compress_transaction( + tx: &Tx, + resolver: &mut dyn PrevoutResolver, + options: CompressOptions, +) -> Result<(Vec, Vec), CodecError> { + if tx.inputs.is_empty() || tx.outputs.is_empty() { + return Err(CodecError::EmptyTransaction); + } + if tx.inputs.len() > MAX_COMPRESSED_INPUTS || tx.outputs.len() > MAX_COMPRESSED_OUTPUTS { + return Err(CodecError::Overflow); + } + if consensus_bytes(tx).len() > MAX_COMPRESSED_TX_BYTES { + return Err(CodecError::Overflow); + } + + // Resolve every prevout once. Resolver resource failures propagate; + // ordinary absence degrades per-input below. + let mut resolved: Vec> = Vec::with_capacity(tx.inputs.len()); + let mut degraded: Vec> = Vec::with_capacity(tx.inputs.len()); + for (index, input) in tx.inputs.iter().enumerate() { + if index % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + return Err(CodecError::Cancelled); + } + match resolver.prevout(&input.previous_output) { + Ok(prevout) => { + degraded.push(None); + resolved.push(prevout); + } + Err(LookupError::LimitExceeded(limit)) => { + return Err(CodecError::LimitExceeded(limit)); + } + Err(LookupError::Cancelled) => return Err(CodecError::Cancelled), + Err(error) => { + let reason = match error { + LookupError::Missing => "prevout context is unavailable".to_owned(), + LookupError::BodyMissing(height) => { + format!("prevout block body at height {height} is unavailable") + } + LookupError::CorruptBody(height) => { + format!("prevout block body at height {height} is corrupt") + } + LookupError::BodyReadFailed(height) => { + format!("prevout block body at height {height} could not be read") + } + LookupError::LimitExceeded(_) | LookupError::Cancelled => { + "prevout context is unavailable".to_owned() + } + }; + degraded.push(Some(reason)); + resolved.push(None); + } + } + } + + // Reference minimum-height rule (module docs, decision 4). + let locktime = tx.lock_time.to_consensus(); + let mut min_height: Option = if locktime != 0 { + Some(locktime - 1) + } else { + None + }; + if options.compress_outpoints { + for prevout in resolved.iter().flatten() { + if let Some(height) = prevout.confirmed_height { + let candidate = height.saturating_sub(1); + min_height = Some(min_height.map_or(candidate, |m: u32| m.min(candidate))); + } + } + } + let min_height = min_height.unwrap_or(0); + + // Group flat-index lookups by required height so each body is loaded + // once: heights are already known from the resolved prevouts, so running + // one height's lookups consecutively keeps every lookup a retained-body + // hit instead of evicting and reloading the body per input. Heights run + // in first-appearance order to keep failure precedence closest to input + // order. Results are cached per input; the plan pass below still runs in + // input order, so warnings keep their `UTXO(i)` order and content, and + // proved-absent inputs still degrade rather than fail. + let mut flat_results: Vec, LookupError>>> = + vec![None; tx.inputs.len()]; + if options.compress_outpoints { + let (order, members) = flat_lookup_groups(tx, &resolved, °raded); + flat_results = resolve_flats_grouped(tx, resolver, &order, &members)?; + } + + // Per-input encoding plan. + let mut plans: Vec = Vec::with_capacity(tx.inputs.len()); + let mut warnings: Vec = Vec::new(); + for (index, input) in tx.inputs.iter().enumerate() { + let prevout = resolved.get(index).and_then(Option::as_ref); + let signature = prevout.and_then(|prevout| { + compressed_signature(input, &prevout_kind(prevout.txout.script_pubkey.as_bytes())) + }); + let mut outpoint_compressed = false; + let mut height_offset = 0_u64; + let mut flat_index = 0_u64; + if options.compress_outpoints { + let reason = match (prevout, degraded.get(index).and_then(Option::as_ref)) { + (_, Some(reason)) => Some(reason.clone()), + (None, None) => Some("prevout not found".to_owned()), + (Some(prevout), None) => match prevout.confirmed_height { + None => Some("prevout is unconfirmed".to_owned()), + Some(height) => match flat_results[index] { + Some(Ok(Some(flat))) => { + height_offset = u64::from( + height.checked_sub(min_height).ok_or(CodecError::Overflow)?, + ); + flat_index = u64::from(flat); + outpoint_compressed = true; + None + } + Some(Ok(None)) => { + Some("prevout transaction not found in its block".to_owned()) + } + Some(Err(LookupError::LimitExceeded(limit))) => { + return Err(CodecError::LimitExceeded(limit)); + } + Some(Err(LookupError::Cancelled)) => return Err(CodecError::Cancelled), + Some(Err(LookupError::Missing)) => { + Some("prevout context is unavailable".to_owned()) + } + Some(Err(LookupError::BodyMissing(h))) => { + Some(format!("prevout block body at height {h} is unavailable")) + } + Some(Err(LookupError::CorruptBody(h))) => { + Some(format!("prevout block body at height {h} is corrupt")) + } + Some(Err(LookupError::BodyReadFailed(h))) => Some(format!( + "prevout block body at height {h} could not be read" + )), + None => Some("prevout context is unavailable".to_owned()), + }, + }, + }; + if let Some(reason) = reason { + warnings.push(format!("UTXO({index}): {reason}")); + } + } + plans.push(Plan { + outpoint_compressed, + height_offset, + flat_index, + signature, + sequence: input.sequence.to_consensus(), + }); + } + + // Transaction metadata and the fields its flags leave non-literal. + let flag = |value: u32| -> u8 { + if (1..=3).contains(&value) { + u8::try_from(value).unwrap_or(0) + } else { + 0 + } + }; + let input_count = u32::try_from(tx.inputs.len()).map_err(|_| CodecError::Overflow)?; + let output_count = u32::try_from(tx.outputs.len()).map_err(|_| CodecError::Overflow)?; + let mut metadata = flag(tx.version); + metadata |= flag(input_count) << 2; + metadata |= flag(output_count) << 4; + if locktime != 0 { + metadata |= 1 << 6; + } + if min_height != 0 { + metadata |= 1 << 7; + } + + let mut writer = Writer::new(); + writer.write_u8(metadata)?; + if flag(tx.version) == 0 { + writer.write_compact_size(u64::from(tx.version))?; + } + if flag(input_count) == 0 { + writer.write_compact_size(u64::from(input_count))?; + } + if flag(output_count) == 0 { + writer.write_compact_size(u64::from(output_count))?; + } + if locktime != 0 { + let stored = locktime + .checked_sub(min_height) + .ok_or(CodecError::Overflow)?; + writer.write_compact_size(u64::from(stored))?; + } + if min_height != 0 { + writer.write_varint(u64::from(min_height))?; + } + + // Packed input/output metadata. + let mut input_meta: Vec = Vec::with_capacity(plans.len()); + for plan in &plans { + let mut meta = 0_u8; + if let Some(signature) = &plan.signature { + meta |= 1; + if signature.pubkey_hash.is_some() { + meta |= 1 << 1; + } + if signature.standard { + meta |= 1 << 2; + } + } + meta |= sequence_code(plan.sequence) << 3; + if plan.outpoint_compressed { + meta |= 1 << 5; + } + input_meta.push(meta); + } + let mut output_meta: Vec = Vec::with_capacity(tx.outputs.len()); + for output in &tx.outputs { + output_meta.push(output_code(output_shape(output.script_pubkey.as_bytes()).0)); + } + writer.write(&encode_bits(&input_meta, &output_meta))?; + + // Input data. + for (index, (input, plan)) in tx.inputs.iter().zip(plans.iter()).enumerate() { + let _ = index; + if plan.outpoint_compressed { + writer.write_varint(plan.height_offset)?; + writer.write_varint(plan.flat_index)?; + } else { + writer.write(input.previous_output.txid.as_bytes())?; + writer.write_compact_size(u64::from(input.previous_output.vout))?; + } + if let Some(signature) = &plan.signature { + writer.write(&signature.sig)?; + if let Some(pubkey_hash) = &signature.pubkey_hash { + writer.write(pubkey_hash)?; + } + if !signature.standard { + writer.write_u8(signature.hash_type.ok_or(CodecError::NonCanonical)?)?; + } + } else { + writer.write_vlp(input.script_sig.as_bytes())?; + writer.write_varint( + u64::try_from(input.witness.len()).map_err(|_| CodecError::Overflow)?, + )?; + for item in &input.witness { + writer.write_vlp(item)?; + } + } + if sequence_code(plan.sequence) == 0 { + writer.write_varint(u64::from(plan.sequence))?; + } + } + + // Output data. + for output in &tx.outputs { + let (shape, payload) = output_shape(output.script_pubkey.as_bytes()); + if shape == OutputShape::Custom { + writer.write_vlp(payload)?; + } else { + writer.write(payload)?; + } + writer.write_varint(output.value.to_sat())?; + } + + Ok((writer.bytes, warnings)) +} + +// --------------------------------------------------------------------- +// Decompress +// --------------------------------------------------------------------- + +enum SigParts { + Compressed { + sig: [u8; 64], + pubkey_hash: Option<[u8; 20]>, + hash_type: Option, + standard: bool, + }, + Uncompressed { + script_sig: Vec, + witness: Vec>, + }, +} + +/// One input's parsed fields: everything the byte stream says before any +/// resolver call. Splitting parse from resolution lets the resolver run +/// grouped by required height (each body loaded once) instead of cycling +/// retained bodies in input order. +struct ParsedInput { + /// Compressed outpoint reference; `None` carries `raw_outpoint`. + height_flat: Option<(u32, u32)>, + /// Literal outpoint when `height_flat` is `None`. + raw_outpoint: Option, + sequence: u32, + signature: SigParts, +} + +struct InputParts { + outpoint: OutPoint, + resolved: Option, + sequence: u32, + signature: SigParts, +} + +/// One public-key recovery attempt: the input under reconstruction and +/// the resolved prevout commitment. The sighash cache over the immutable +/// skeleton is passed separately so one cache serves every input. +struct Recovery<'a> { + index: usize, + sig: &'a [u8; 64], + hash_type: u8, + kind: &'a PrevoutKind, + prevout: &'a ResolvedPrevout, + stored_pkh: Option<&'a [u8; 20]>, +} + +/// Recovers the public key for one compressed ECDSA input, grinding +/// recovery ids `0..=3` against the resolved sighash and prevout script. +/// `cache` must borrow the immutable skeleton every recovery input +/// commits to; callers build it once per [`decompress_finalize`] call. +fn recover_pubkey(cache: &SighashCache<'_>, request: &Recovery<'_>) -> Result, CodecError> { + let digest = match request.kind { + PrevoutKind::P2pkh(_) => cache + .legacy_signature_hash( + request.index, + request.prevout.txout.script_pubkey.as_bytes(), + u32::from(request.hash_type), + ) + .map_err(|_| CodecError::Sighash)?, + PrevoutKind::P2wpkh(hash) | PrevoutKind::P2sh(hash) => { + let script_hash = match request.kind { + PrevoutKind::P2wpkh(hash) => hash, + PrevoutKind::P2sh(_) => request.stored_pkh.ok_or(CodecError::UnsupportedScript)?, + _ => hash, + }; + let mut script_code = Vec::with_capacity(25); + script_code.extend_from_slice(&[0x76, 0xa9, 0x14]); + script_code.extend_from_slice(script_hash); + script_code.extend_from_slice(&[0x88, 0xac]); + cache + .segwit_v0_signature_hash_raw( + request.index, + &script_code, + request.prevout.txout.value, + u32::from(request.hash_type), + ) + .map_err(|_| CodecError::Sighash)? + } + PrevoutKind::P2wsh | PrevoutKind::P2tr | PrevoutKind::Other => { + return Err(CodecError::UnsupportedScript); + } + }; + let message = Message::from_digest(*digest.as_byte_array()); + let secp = Secp256k1::verification_only(); + for recovery in 0..=3_i32 { + let Ok(recovery_id) = RecoveryId::from_i32(recovery) else { + continue; + }; + let Ok(recoverable) = RecoverableSignature::from_compact(request.sig, recovery_id) else { + continue; + }; + let Ok(public) = secp.recover_ecdsa(&message, &recoverable) else { + continue; + }; + let compressed = public.serialize(); + match request.kind { + PrevoutKind::P2pkh(hash) => { + if hash160_20(&compressed) == *hash { + return Ok(compressed.to_vec()); + } + let uncompressed = public.serialize_uncompressed(); + if hash160_20(&uncompressed) == *hash { + return Ok(uncompressed.to_vec()); + } + } + PrevoutKind::P2wpkh(hash) => { + if hash160_20(&compressed) == *hash { + return Ok(compressed.to_vec()); + } + } + PrevoutKind::P2sh(_) => { + if request + .stored_pkh + .is_some_and(|pkh| hash160_20(&compressed) == *pkh) + { + return Ok(compressed.to_vec()); + } + } + PrevoutKind::P2wsh | PrevoutKind::P2tr | PrevoutKind::Other => { + return Err(CodecError::UnsupportedScript); + } + } + } + Err(CodecError::SignatureUnrecoverable) +} + +/// Resolves compressed outpoints grouped by required height so each body is +/// loaded once no matter how inputs interleave heights. Heights run in +/// first-appearance order to keep failure precedence closest to input +/// order. Raw outpoints need no body and stay out of every group; their +/// slots hold `None`. +fn resolve_outpoints_grouped( + parsed: &[ParsedInput], + resolver: &mut dyn PrevoutResolver, +) -> Result, LookupError>>>, CodecError> { + let mut order: Vec = Vec::new(); + let mut members: HashMap> = HashMap::new(); + for (index, part) in parsed.iter().enumerate() { + if let Some((height, _)) = part.height_flat { + if !members.contains_key(&height) { + order.push(height); + } + members.entry(height).or_default().push(index); + } + } + let mut located: Vec, LookupError>>> = + vec![None; parsed.len()]; + let mut polls = 0_usize; + for &height in &order { + let Some(indices) = members.get(&height) else { + continue; + }; + for &index in indices { + if polls % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + return Err(CodecError::Cancelled); + } + polls += 1; + let Some((_, flat)) = parsed[index].height_flat else { + continue; + }; + located[index] = Some(resolver.outpoint_at(height, flat)); + } + } + Ok(located) +} + +/// Fetches the prevout for a compressed signature whose outpoint arrived +/// raw. Context is mandatory exactly when reconstruction consumes it: every +/// compressed signature needs its prevout script. Compressed outpoints +/// already carry their resolution, so they pass through untouched. +fn resolve_sig_prevout( + resolver: &mut dyn PrevoutResolver, + outpoint: &OutPoint, + resolved: Option, +) -> Result, CodecError> { + match resolved { + Some(prevout) => Ok(Some(prevout)), + None => match resolver.prevout(outpoint) { + Ok(Some(prevout)) => Ok(Some(prevout)), + Ok(None) => Err(CodecError::PrevoutMissing(*outpoint)), + Err(error) => Err(lookup_failure(error, CodecError::PrevoutMissing(*outpoint))), + }, + } +} + +/// The parsed, context-resolved skeleton of one compressed transaction: +/// everything [`decompress_prepare`] reads while the stable chainstate +/// excludes transitions. No public-key recovery has run yet, so holding a +/// plan needs no lock; [`decompress_finalize`] finishes it lock-free. +pub(crate) struct DecompressPlan { + tx: Tx, + inputs: Vec, +} + +/// Parses one compressed transaction and resolves every input that needs +/// context. Unavailable context is a refusal, never a fabricated prevout. +/// Callers hold the stable chainstate across this call only; recovery runs +/// in [`decompress_finalize`] without the lock. +/// +/// # Errors +/// Returns [`CodecError`] for malformed encodings, unavailable context, +/// budget exhaustion, and cancellation. +#[allow( + clippy::too_many_lines, + reason = "one sequential decoding pass over the transaction fields" +)] +pub(crate) fn decompress_prepare( + bytes: &[u8], + resolver: &mut dyn PrevoutResolver, +) -> Result { + if bytes.len() > MAX_COMPRESSED_TX_BYTES { + return Err(CodecError::Overflow); + } + let mut reader = Reader::new(bytes); + let metadata = reader.read_u8()?; + let version_flag = metadata & 0x03; + let input_flag = (metadata >> 2) & 0x03; + let output_flag = (metadata >> 4) & 0x03; + let has_locktime = metadata & 0x40 != 0; + let has_min_height = metadata & 0x80 != 0; + + let version = if version_flag == 0 { + read_u32_compact_size(&mut reader)? + } else { + u32::from(version_flag) + }; + let input_count = if input_flag == 0 { + usize::try_from(reader.read_compact_size()?).map_err(|_| CodecError::Overflow)? + } else { + usize::from(input_flag) + }; + let output_count = if output_flag == 0 { + usize::try_from(reader.read_compact_size()?).map_err(|_| CodecError::Overflow)? + } else { + usize::from(output_flag) + }; + if input_count == 0 || output_count == 0 { + return Err(CodecError::EmptyTransaction); + } + if input_count > MAX_COMPRESSED_INPUTS || output_count > MAX_COMPRESSED_OUTPUTS { + return Err(CodecError::Overflow); + } + let stored_locktime = if has_locktime { + Some(reader.read_compact_size()?) + } else { + None + }; + let min_height = if has_min_height { + read_u32_varint(&mut reader)? + } else { + 0 + }; + let locktime = match stored_locktime { + Some(stored) => { + let stored = u32::try_from(stored).map_err(|_| CodecError::Overflow)?; + min_height.checked_add(stored).ok_or(CodecError::Overflow)? + } + None => 0, + }; + + let (input_meta, output_meta) = decode_bits(&mut reader, input_count, output_count)?; + + // Parse every input before touching the resolver: the byte stream fixes + // the input order, but resolution runs grouped by height below. Parse + // runs ahead of resolution, so a malformed tail surfaces before an + // unresolvable early input; both are refusals, and single-fault inputs + // map exactly as before. + let mut parsed: Vec = Vec::with_capacity(input_count); + for index in 0..input_count { + if index % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + return Err(CodecError::Cancelled); + } + let meta = *input_meta.get(index).ok_or(CodecError::Truncated)?; + let sig_compressed = meta & 1 != 0; + let pkh_present = meta & 0x02 != 0; + let standard = meta & 0x04 != 0; + let seq_code = (meta >> 3) & 0x03; + let outpoint_compressed = meta & 0x20 != 0; + + let (height_flat, raw_outpoint) = if outpoint_compressed { + let offset = read_u32_varint(&mut reader)?; + let flat = read_u32_varint(&mut reader)?; + let height = min_height.checked_add(offset).ok_or(CodecError::Overflow)?; + (Some((height, flat)), None) + } else { + let mut txid_bytes = [0_u8; 32]; + txid_bytes.copy_from_slice(reader.take(32)?); + let vout = read_u32_compact_size(&mut reader)?; + let outpoint = OutPoint::new( + Txid(bitcoin_rs_primitives::Hash256::from_le_bytes(&txid_bytes)), + vout, + ); + (None, Some(outpoint)) + }; + + let signature = if sig_compressed { + let mut sig = [0_u8; 64]; + sig.copy_from_slice(reader.take(64)?); + let pubkey_hash = if pkh_present { + let mut hash = [0_u8; 20]; + hash.copy_from_slice(reader.take(20)?); + Some(hash) + } else { + None + }; + let hash_type = if standard { + None + } else { + Some(reader.read_u8()?) + }; + SigParts::Compressed { + sig, + pubkey_hash, + hash_type, + standard, + } + } else { + let script_sig = reader.read_vlp()?.to_vec(); + // Item counts stay five-byte capped like every other `u32` field. + let witness_count = reader.read_varint_capped(5)?; + let witness_count = usize::try_from(witness_count).map_err(|_| CodecError::Overflow)?; + if witness_count > reader.remaining() { + return Err(CodecError::Truncated); + } + let mut witness = Vec::with_capacity(witness_count); + for _ in 0..witness_count { + witness.push(reader.read_vlp()?.to_vec()); + } + SigParts::Uncompressed { + script_sig, + witness, + } + }; + let sequence = sequence_value(seq_code, &mut reader)?; + + parsed.push(ParsedInput { + height_flat, + raw_outpoint, + sequence, + signature, + }); + } + + // Group outpoint resolution by required height so each body is loaded + // once no matter how inputs interleave heights. + let mut located = resolve_outpoints_grouped(&parsed, &mut *resolver)?; + + // Assemble in input order, preserving per-input error mapping. Raw + // outpoints with compressed signatures still resolve their prevout here + // in input order; the cumulative body-load budget fails those closed if + // they cycle past the retained slots. + let mut inputs: Vec = Vec::with_capacity(input_count); + for (index, part) in parsed.into_iter().enumerate() { + if index % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + return Err(CodecError::Cancelled); + } + let ParsedInput { + height_flat, + raw_outpoint, + sequence, + signature, + } = part; + let sig_compressed = matches!(signature, SigParts::Compressed { .. }); + let (outpoint, resolved) = match height_flat { + Some((height, _)) => match located[index].take() { + Some(Ok(Some(found))) => (found.0, Some(found.1)), + Some(Ok(None)) => return Err(CodecError::BodyMissing(height)), + Some(Err(error)) => { + return Err(lookup_failure(error, CodecError::BodyMissing(height))); + } + None => return Err(CodecError::BodyMissing(height)), + }, + None => match raw_outpoint { + Some(outpoint) => (outpoint, None), + None => return Err(CodecError::Truncated), + }, + }; + let resolved = if sig_compressed { + resolve_sig_prevout(&mut *resolver, &outpoint, resolved)? + } else { + resolved + }; + + inputs.push(InputParts { + outpoint, + resolved, + sequence, + signature, + }); + } + + let mut outputs: Vec = Vec::with_capacity(output_count); + for index in 0..output_count { + let code = *output_meta.get(index).ok_or(CodecError::Truncated)?; + let script = match code { + 0 => reader.read_vlp()?.to_vec(), + 1 => output_script(1, reader.take(65)?)?, + 2 => output_script(2, reader.take(33)?)?, + 3 => output_script(3, reader.take(20)?)?, + 4 => output_script(4, reader.take(20)?)?, + 5 => output_script(5, reader.take(20)?)?, + 6 => output_script(6, reader.take(32)?)?, + 7 => output_script(7, reader.take(32)?)?, + _ => return Err(CodecError::NonCanonical), + }; + let amount = reader.read_varint()?; + outputs.push(TxOut { + value: Amount::from_sat(amount), + script_pubkey: Script::from_bytes(script), + }); + } + + if reader.remaining() != 0 { + return Err(CodecError::NonCanonical); + } + + // Assemble the skeleton first: sighashes commit to outpoints, + // sequences, outputs, and locktime, but never to the scriptSig or + // witness being reconstructed, so placeholders are safe. + let tx = Tx { + version, + inputs: inputs + .iter() + .map(|part| TxIn { + previous_output: part.outpoint, + script_sig: Script::new(), + sequence: Sequence::from_consensus(part.sequence), + witness: Witness::new(), + }) + .collect(), + outputs, + lock_time: LockTime::from_consensus(locktime), + }; + Ok(DecompressPlan { tx, inputs }) +} + +/// Reconstructs every compressed authorization against the plan's resolved +/// prevouts. Runs without the stable chainstate: recovery only reads the +/// immutable skeleton and resolved data, never the chain. Cancellation is +/// polled per input like the parse pass; input count stays bounded by +/// [`MAX_COMPRESSED_INPUTS`] on both passes. +/// +/// # Errors +/// Returns [`CodecError`] for unrecoverable signatures and cancellation. +pub(crate) fn decompress_finalize( + plan: DecompressPlan, + resolver: &dyn PrevoutResolver, +) -> Result { + let DecompressPlan { mut tx, inputs } = plan; + // One cache over the immutable skeleton serves every input: sighash + // midstates are per-transaction, so rebuilding them per input repeats + // the same hashing work. The skeleton is only mutated after all fills + // are computed, so the shared borrow stays valid for the whole loop. + let cache = SighashCache::new(&tx); + // Reconstruction reads the immutable skeleton (sighash inputs), so + // compute every authorization first and apply them afterwards. + let mut fills: Vec<(Script, Witness)> = Vec::with_capacity(inputs.len()); + for (index, part) in inputs.iter().enumerate() { + if index % CANCELLATION_POLL_INPUTS == 0 && resolver.cancelled() { + return Err(CodecError::Cancelled); + } + let fill = match &part.signature { + SigParts::Uncompressed { + script_sig, + witness, + } => ( + Script::from_bytes(script_sig.clone()), + Witness::from_stack(witness.clone()), + ), + SigParts::Compressed { + sig, + pubkey_hash, + hash_type, + standard, + } => { + let prevout = part + .resolved + .as_ref() + .ok_or(CodecError::PrevoutMissing(part.outpoint))?; + let kind = prevout_kind(prevout.txout.script_pubkey.as_bytes()); + match kind { + PrevoutKind::P2tr => { + if pubkey_hash.is_some() { + return Err(CodecError::UnsupportedScript); + } + let mut item = sig.to_vec(); + if !standard { + item.push(hash_type.ok_or(CodecError::NonCanonical)?); + } + (Script::new(), Witness::from_stack(vec![item])) + } + PrevoutKind::P2pkh(_) | PrevoutKind::P2wpkh(_) | PrevoutKind::P2sh(_) => { + let hash_type_byte = if *standard { + 1_u8 + } else { + hash_type.ok_or(CodecError::NonCanonical)? + }; + let public = recover_pubkey( + &cache, + &Recovery { + index, + sig, + hash_type: hash_type_byte, + kind: &kind, + prevout, + stored_pkh: pubkey_hash.as_ref(), + }, + )?; + let mut sig_item = der_encode(sig); + sig_item.push(hash_type_byte); + match kind { + PrevoutKind::P2pkh(_) => { + let mut script_sig = push_data(&sig_item); + script_sig.extend_from_slice(&push_data(&public)); + (Script::from_bytes(script_sig), Witness::new()) + } + PrevoutKind::P2wpkh(_) => { + (Script::new(), Witness::from_stack(vec![sig_item, public])) + } + PrevoutKind::P2sh(script_hash) => { + let redeem_hash = + pubkey_hash.ok_or(CodecError::UnsupportedScript)?; + let mut redeem = Vec::with_capacity(22); + redeem.extend_from_slice(&[0x00, 0x14]); + redeem.extend_from_slice(&redeem_hash); + if hash160_20(&redeem) != script_hash { + return Err(CodecError::UnsupportedScript); + } + ( + Script::from_bytes(push_data(&redeem)), + Witness::from_stack(vec![sig_item, public]), + ) + } + PrevoutKind::P2wsh | PrevoutKind::P2tr | PrevoutKind::Other => { + return Err(CodecError::UnsupportedScript); + } + } + } + PrevoutKind::P2wsh | PrevoutKind::Other => { + return Err(CodecError::UnsupportedScript); + } + } + } + }; + fills.push(fill); + } + for (input, (script_sig, witness)) in tx.inputs.iter_mut().zip(fills) { + input.script_sig = script_sig; + input.witness = witness; + } + + if consensus_bytes(&tx).len() > MAX_COMPRESSED_TX_BYTES { + return Err(CodecError::Overflow); + } + Ok(tx) +} + +/// Decompresses one compressed transaction. Unavailable context is a +/// refusal, never a fabricated prevout. +/// +/// Test-only convenience: parses, resolves, and recovers in one step. +/// Handlers that must not block chain transitions split the work across +/// [`decompress_prepare`] and [`decompress_finalize`] instead. +/// +/// # Errors +/// Returns [`CodecError`] for malformed encodings, unavailable context, +/// unrecoverable signatures, budget exhaustion, and cancellation. +#[cfg(test)] +pub(crate) fn decompress_transaction( + bytes: &[u8], + resolver: &mut dyn PrevoutResolver, +) -> Result { + let plan = decompress_prepare(bytes, resolver)?; + decompress_finalize(plan, resolver) +} + +#[cfg(test)] +mod tests; diff --git a/crates/rpc/src/compressed_tx/tests.rs b/crates/rpc/src/compressed_tx/tests.rs new file mode 100644 index 000000000..70248f64b --- /dev/null +++ b/crates/rpc/src/compressed_tx/tests.rs @@ -0,0 +1,1965 @@ +//! Codec tests: published BIP337 vectors (fixture context derived from +//! real mainnet block bodies), signed round-trips per spend type, and +//! malformed/context failure modes. +//! +//! The fixture at `crates/rpc/tests/bip337_vectors.json` was derived +//! once from the published vectors plus mempool.space mainnet block +//! bodies at heights 833265..833280 by a throwaway generator; no context +//! field was written by hand. The ignored `mainnet_bodies_truth` test +//! re-derives every field from the bodies themselves. + +#![expect( + clippy::unwrap_used, + reason = "test assertions fail with the checked value in the message" +)] + +use std::collections::HashMap; +use std::str::FromStr as _; + +use bitcoin::hex::FromHex as _; +use bitcoin::secp256k1::{Keypair, Message, PublicKey, Secp256k1, SecretKey}; +use bitcoin_rs_primitives::{ + Amount, Hash256, LockTime, OutPoint, Script, Sequence, Sighash, SighashCache, Tx, TxIn, TxOut, + Txid, Witness, consensus_bytes, deserialize, layout::ParsedBlock, +}; +use sonic_rs::{JsonContainerTrait as _, JsonValueTrait as _, Value}; + +use super::{ + CodecError, CompressOptions, LookupError, ResolvedPrevout, compress_transaction, + decompress_transaction, encode_bits, write_varint, +}; +use crate::compressed_tx::PrevoutResolver; + +const FIXTURE: &str = include_str!("../../tests/bip337_vectors.json"); + +#[derive(Default)] +struct MapResolver { + prevouts: HashMap, + flats: HashMap<(u32, Txid, u32), u32>, + at: HashMap<(u32, u32), (OutPoint, ResolvedPrevout)>, + cancelled: bool, +} + +impl MapResolver { + fn register(&mut self, height: u32, txid: Txid, vout: u32, first_flat: u32, txout: TxOut) { + let outpoint = OutPoint::new(txid, vout); + let resolved = ResolvedPrevout { + txout, + confirmed_height: Some(height), + }; + self.prevouts.insert(outpoint, resolved.clone()); + self.flats.insert((height, txid, vout), first_flat + vout); + self.at + .insert((height, first_flat + vout), (outpoint, resolved)); + } + + /// Registers every output of `tx` as confirmed at `height`, with + /// `first_flat` outputs preceding it in the block. + fn register_tx(&mut self, height: u32, tx: &Tx, first_flat: u32) { + for (vout, output) in tx.outputs.iter().enumerate() { + let vout = u32::try_from(vout).unwrap(); + self.register(height, tx.txid(), vout, first_flat, output.clone()); + } + } +} + +impl PrevoutResolver for MapResolver { + fn prevout(&mut self, outpoint: &OutPoint) -> Result, LookupError> { + Ok(self.prevouts.get(outpoint).cloned()) + } + + fn flat_output_index( + &mut self, + height: u32, + txid: &Txid, + vout: u32, + ) -> Result, LookupError> { + Ok(self.flats.get(&(height, *txid, vout)).copied()) + } + + fn outpoint_at( + &mut self, + height: u32, + flat_index: u32, + ) -> Result, LookupError> { + Ok(self.at.get(&(height, flat_index)).cloned()) + } + + fn cancelled(&self) -> bool { + self.cancelled + } +} + +fn hex_bytes(text: &str) -> Vec { + Vec::::from_hex(text).unwrap() +} + +fn fixture_vectors() -> Vec { + let parsed: Value = sonic_rs::from_str(FIXTURE).unwrap(); + parsed + .get("vectors") + .and_then(Value::as_array) + .unwrap() + .iter() + .cloned() + .collect() +} + +fn fixture_resolver(vector: &Value) -> MapResolver { + let mut resolver = MapResolver::default(); + for input in vector.get("inputs").and_then(Value::as_array).unwrap() { + let txid = + Txid::from_str(input.get("prevout_txid").and_then(Value::as_str).unwrap()).unwrap(); + let vout = u32::try_from(input.get("vout").and_then(Value::as_u64).unwrap()).unwrap(); + let height = u32::try_from(input.get("height").and_then(Value::as_u64).unwrap()).unwrap(); + let flat = u32::try_from( + input + .get("flat_output_index") + .and_then(Value::as_u64) + .unwrap(), + ) + .unwrap(); + let txout = TxOut { + value: Amount::from_sat(input.get("value_sats").and_then(Value::as_u64).unwrap()), + script_pubkey: Script::from_bytes(hex_bytes( + input + .get("prevout_script_hex") + .and_then(Value::as_str) + .unwrap(), + )), + }; + resolver.register(height, txid, vout, flat - vout, txout); + } + resolver +} + +#[test] +fn varint_uses_core_carry_form() { + let mut encoded = Vec::new(); + write_varint(5000, &mut encoded).unwrap(); + assert_eq!(encoded, [0xa6, 0x08]); + let mut reader = super::Reader::new(&encoded); + assert_eq!(super::Reader::read_varint(&mut reader).unwrap(), 5000); + for value in [ + 0_u64, + 1, + 127, + 128, + 16_511, + 4_294_967_293, + 1_u64 << 40, + 2_100_000_000_000_000, + u64::MAX, + ] { + let mut encoded = Vec::new(); + write_varint(value, &mut encoded).unwrap(); + let mut reader = super::Reader::new(&encoded); + assert_eq!(reader.read_varint().unwrap(), value); + } + // Ten bytes carry every `u64`: the largest amount keeps a spelling. + let mut encoded = Vec::new(); + write_varint(u64::MAX, &mut encoded).unwrap(); + assert_eq!(encoded.len(), 10); + let mut reader = super::Reader::new(&encoded); + assert_eq!(reader.read_varint().unwrap(), u64::MAX); + // Six bytes carry past `u32`: still a valid `u64` spelling. + let mut encoded = Vec::new(); + write_varint(1_u64 << 40, &mut encoded).unwrap(); + assert_eq!(encoded.len(), 6); + let mut reader = super::Reader::new(&encoded); + assert_eq!(reader.read_varint().unwrap(), 1_u64 << 40); + // `u32` fields keep their five-byte ceiling: a six-byte spelling is + // out of range even when the value would fit. + let mut reader = super::Reader::new(&encoded); + assert_eq!( + super::read_u32_varint(&mut reader), + Err(CodecError::Overflow) + ); + // Eleven continuation bytes never terminate within the ten-byte cap. + let unterminated = [0x80; 11]; + let mut reader = super::Reader::new(&unterminated); + assert_eq!(reader.read_varint(), Err(CodecError::Overflow)); +} + +#[test] +fn encode_bits_matches_published_example() { + assert_eq!(encode_bits(&[37], &[5]), [0x96, 0x80]); +} + +#[test] +fn handlers_gate_on_the_runtime_opt_in() { + use std::sync::Arc; + + use sonic_rs::json; + + use crate::context::{Context, ContextHandles}; + use crate::handlers::Handler; + + let tx = funding_tx(TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2wpkh_script([1_u8; 20]), + }); + let raw_hex = bitcoin::hex::DisplayHex::to_lower_hex_string(&consensus_bytes(&tx)); + + let disabled = Handler::new(Arc::new(Context::new())); + for method in ["compressrawtransaction", "decompressrawtransaction"] { + let error = disabled.dispatch(method, &json!([raw_hex])).unwrap_err(); + assert!( + matches!(error, crate::RpcError::MethodDisabled(_)), + "{method}: {error:?}" + ); + assert_eq!(error.code(), -32603, "{method} must never answer -32601"); + } + + let enabled = Handler::new(Arc::new(Context::from_handles(ContextHandles { + compressed_transactions: true, + ..ContextHandles::default() + }))); + let compressed = enabled + .dispatch("compressrawtransaction", &json!([raw_hex])) + .unwrap(); + let compressed_hex = compressed + .get("result") + .and_then(Value::as_str) + .unwrap() + .to_owned(); + assert_eq!( + compressed + .get("warnings") + .and_then(Value::as_array) + .unwrap() + .len(), + 1 + ); + let restored = enabled + .dispatch("decompressrawtransaction", &json!([compressed_hex])) + .unwrap(); + assert_eq!(restored.as_str().unwrap(), raw_hex); + // Oversize hex is refused (-8) before any decoding happens. + let oversize = "00".repeat(super::MAX_COMPRESSED_TX_BYTES + 1); + let error = enabled + .dispatch("compressrawtransaction", &json!([oversize])) + .unwrap_err(); + assert_eq!(error.code(), -8); +} + +#[test] +fn codec_error_wire_matrix() { + use super::Limit; + use crate::RpcError; + + for (error, code) in [ + (CodecError::Truncated, -22), + (CodecError::NonCanonical, -22), + (CodecError::EmptyTransaction, -22), + (CodecError::UnsupportedScript, -22), + (CodecError::Sighash, -22), + (CodecError::SignatureUnrecoverable, -22), + (CodecError::Overflow, -8), + (CodecError::LimitExceeded(Limit::State), -1), + (CodecError::Cancelled, -1), + ( + CodecError::PrevoutMissing(OutPoint::new(Txid(Hash256::from_le_bytes(&[1_u8; 32])), 0)), + -5, + ), + (CodecError::BodyMissing(7), -1), + (CodecError::CorruptBody(7), -1), + (CodecError::BodyReadFailed(7), -1), + ] { + assert_eq!(RpcError::from(error).code(), code); + } +} + +#[test] +fn registry_rows_are_extensions_at_package_version() { + for name in ["compressrawtransaction", "decompressrawtransaction"] { + let entry = crate::manifest::MANIFEST + .iter() + .find(|entry| entry.name == name) + .unwrap(); + assert_eq!(entry.status, crate::manifest::Status::Extension); + assert_eq!(entry.feature, ""); + assert_eq!(entry.core_version, ""); + assert_eq!(entry.since, bitcoin_rs_primitives::PKG_VERSION); + } +} + +#[test] +fn published_vectors_restore_byte_for_byte() { + for vector in fixture_vectors() { + let name = vector.get("name").and_then(Value::as_str).unwrap(); + let raw_hex = vector.get("raw_hex").and_then(Value::as_str).unwrap(); + let compressed_hex = vector + .get("compressed_hex") + .and_then(Value::as_str) + .unwrap(); + let raw: Tx = deserialize(&hex_bytes(raw_hex)).unwrap(); + assert_eq!( + raw.txid().to_string(), + vector.get("txid").and_then(Value::as_str).unwrap(), + "{name}" + ); + assert_eq!( + raw.wtxid().to_string(), + vector.get("wtxid").and_then(Value::as_str).unwrap(), + "{name}" + ); + let mut resolver = fixture_resolver(&vector); + let (compressed, warnings) = compress_transaction( + &raw, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert!(warnings.is_empty(), "{name}: {warnings:?}"); + assert_eq!( + compressed, + hex_bytes(compressed_hex), + "{name}: compressed bytes differ" + ); + let mut resolver = fixture_resolver(&vector); + let restored = decompress_transaction(&compressed, &mut resolver).unwrap(); + assert_eq!(restored, raw, "{name}: round trip differs"); + assert_eq!(consensus_bytes(&restored), hex_bytes(raw_hex), "{name}"); + } +} + +/// Derives (prev transaction, spending transaction) signed test pairs. +struct Signer { + secp: Secp256k1, + secret: SecretKey, + public: PublicKey, +} + +impl Signer { + fn new(seed: u8) -> Self { + let secp = Secp256k1::new(); + let secret = SecretKey::from_slice(&[seed; 32]).unwrap(); + let public = PublicKey::from_secret_key(&secp, &secret); + Self { + secp, + secret, + public, + } + } + + fn compressed_pubkey(&self) -> Vec { + self.public.serialize().to_vec() + } + + fn uncompressed_pubkey(&self) -> Vec { + self.public.serialize_uncompressed().to_vec() + } + + fn pubkey_hash(&self) -> [u8; 20] { + super::hash160_20(&self.compressed_pubkey()) + } + + /// Signs `digest` as ECDSA; `high_s` flips `s` to `n - s`. + fn ecdsa_item(&self, digest: Hash256, high_s: bool) -> Vec { + let message = Message::from_digest(*digest.as_byte_array()); + let signature = self.secp.sign_ecdsa(&message, &self.secret); + let mut compact = signature.serialize_compact(); + if high_s { + // s := n - s over the secp256k1 group order. + const ORDER: [u8; 32] = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xfe, 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, + 0xd0, 0x36, 0x41, 0x41, + ]; + let mut borrow = 0_i32; + for index in (32..64).rev() { + let diff = i32::from(ORDER[index - 32]) - i32::from(compact[index]) - borrow; + if diff < 0 { + compact[index] = u8::try_from(diff + 256).unwrap(); + borrow = 1; + } else { + compact[index] = u8::try_from(diff).unwrap(); + borrow = 0; + } + } + } + let parsed = bitcoin::secp256k1::ecdsa::Signature::from_compact(&compact).unwrap(); + let mut item = parsed.serialize_der().to_vec(); + item.push(1); + item + } +} + +fn funding_tx(output: TxOut) -> Tx { + Tx { + version: 2, + inputs: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: Script::from_bytes(vec![0x01, 0x2a]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + outputs: vec![output], + lock_time: LockTime::ZERO, + } +} + +fn spend_of(prevout: &Tx, script_sig: Script, witness: Witness) -> Tx { + Tx { + version: 2, + inputs: vec![TxIn { + previous_output: OutPoint::new(prevout.txid(), 0), + script_sig, + sequence: Sequence::from_consensus(0xffff_fffd), + witness, + }], + outputs: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: Script::from_bytes( + vec![0x00, 0x14].into_iter().chain([7_u8; 20]).collect(), + ), + }], + lock_time: LockTime::ZERO, + } +} + +fn p2pkh_script(hash: [u8; 20]) -> Script { + let mut script = vec![0x76, 0xa9, 0x14]; + script.extend_from_slice(&hash); + script.extend_from_slice(&[0x88, 0xac]); + Script::from_bytes(script) +} + +fn p2wpkh_script(hash: [u8; 20]) -> Script { + let mut script = vec![0x00, 0x14]; + script.extend_from_slice(&hash); + Script::from_bytes(script) +} + +fn roundtrip(tx: &Tx, resolver: &mut MapResolver, compress_outpoints: bool) -> Vec { + let (compressed, _) = + compress_transaction(tx, resolver, CompressOptions { compress_outpoints }).unwrap(); + let restored = decompress_transaction(&compressed, resolver).unwrap(); + assert_eq!(&restored, tx); + compressed +} + +#[test] +fn roundtrip_legacy_p2pkh() { + let signer = Signer::new(7); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&signer.compressed_pubkey())); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn roundtrip_legacy_p2pkh_uncompressed_key() { + // The 65-byte `0x04` form round-trips through recovery exactly. + let signer = Signer::new(7); + let uncompressed = signer.uncompressed_pubkey(); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(super::hash160_20(&uncompressed)), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&uncompressed)); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn hybrid_p2pkh_key_falls_back_to_uncompressed_envelope() { + // A hybrid (non-round-tripping) pubkey must not compress: recovery + // could never reproduce it, so the input keeps its exact bytes. + let signer = Signer::new(7); + let mut hybrid = signer.uncompressed_pubkey(); + hybrid[0] = 0x06; + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(super::hash160_20(&hybrid)), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&hybrid)); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn foreign_p2pkh_key_falls_back_instead_of_changing_signer() { + // A valid key that does not hash to the prevout commitment must not + // compress: rebuilding from recovery would silently swap the signer. + let signer = Signer::new(7); + let stranger = Signer::new(19); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&stranger.compressed_pubkey())); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn lossy_input_shapes_fall_back_to_uncompressed_envelope() { + // Each shape carries bytes the compressed rebuild cannot reproduce, so + // the round trip must still restore the exact transaction. + let signer = Signer::new(7); + let stranger = Signer::new(19); + + // P2PKH spend that also carries a witness item. + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&signer.compressed_pubkey())); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + tx.inputs[0].witness = Witness::from_stack(vec![vec![0x2a]]); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&tx, &mut resolver, true); + + // P2WPKH spend whose witness key does not hash to the prevout. + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2wpkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .segwit_v0_signature_hash_raw( + 0, + p2pkh_script(signer.pubkey_hash()).as_bytes(), + prevout.value, + 1, + ) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + tx.inputs[0].witness = Witness::from_stack(vec![sig_item, stranger.compressed_pubkey()]); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); + + // P2TR key-path spend that also carries scriptSig bytes. + let mut script = vec![0x51, 0x20]; + script.extend_from_slice(&[3_u8; 32]); + let prev_tx = funding_tx(TxOut { + value: Amount::from_sat(50_000), + script_pubkey: Script::from_bytes(script), + }); + let tx = spend_of( + &prev_tx, + Script::from_bytes(vec![0x01, 0x2a]), + Witness::from_stack(vec![vec![4_u8; 64]]), + ); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn nonminimal_p2pkh_pushes_fall_back_to_uncompressed_envelope() { + // Non-minimal PUSHDATA spellings carry the same payloads but rebuild + // to different bytes via `push_data` on decompress, so they must not + // take the compressed path. They fall back to the uncompressed + // envelope and survive the round trip byte-for-byte. + fn pushdata1(data: &[u8]) -> Vec { + let mut out = vec![0x4c, u8::try_from(data.len()).unwrap()]; + out.extend_from_slice(data); + out + } + + fn pushdata2(data: &[u8]) -> Vec { + let len = u16::try_from(data.len()).unwrap().to_le_bytes(); + let mut out = vec![0x4d, len[0], len[1]]; + out.extend_from_slice(data); + out + } + + let signer = Signer::new(7); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut canonical = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&canonical) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let pubkey = signer.compressed_pubkey(); + // Positive control: the canonical spelling still compresses. + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&pubkey)); + canonical.inputs[0].script_sig = Script::from_bytes(script_sig); + let kind = super::prevout_kind(prevout.script_pubkey.as_bytes()); + assert!( + super::compressed_signature(&canonical.inputs[0], &kind).is_some(), + "canonical P2PKH pushes must take the compressed path" + ); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&canonical, &mut resolver, true); + + // The legacy sighash does not commit to the scriptSig framing, so this + // payload-identical `sig_item` stays valid across push spellings. + for script_sig in [ + { + let mut raw = pushdata1(&sig_item); + raw.extend_from_slice(&pushdata1(&pubkey)); + raw + }, + { + let mut raw = pushdata2(&sig_item); + raw.extend_from_slice(&pushdata2(&pubkey)); + raw + }, + { + let mut raw = pushdata1(&sig_item); + raw.extend_from_slice(&super::push_data(&pubkey)); + raw + }, + ] { + let tx = spend_of(&prev_tx, Script::from_bytes(script_sig), Witness::new()); + let kind = super::prevout_kind(prevout.script_pubkey.as_bytes()); + assert!( + super::compressed_signature(&tx.inputs[0], &kind).is_none(), + "non-minimal P2PKH pushes must fall back to the uncompressed envelope" + ); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + roundtrip(&tx, &mut resolver, true); + } +} + +#[test] +fn decompress_prepare_finalize_matches_single_pass() { + // The handler recovers outside the stable chainstate lock; the split + // entry points must agree with the single-pass wrapper exactly. + let signer = Signer::new(23); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout.script_pubkey.as_bytes(), 1) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + let mut script_sig = super::push_data(&sig_item); + script_sig.extend_from_slice(&super::push_data(&signer.compressed_pubkey())); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 3); + let (compressed, _) = compress_transaction( + &tx, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + let plan = super::decompress_prepare(&compressed, &mut resolver).unwrap(); + let restored = super::decompress_finalize(plan, &resolver).unwrap(); + assert_eq!(&restored, &tx); + assert_eq!( + &super::decompress_transaction(&compressed, &mut resolver).unwrap(), + &tx + ); +} + +#[test] +fn roundtrip_p2wpkh_including_high_s() { + for high_s in [false, true] { + let signer = Signer::new(9); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2wpkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .segwit_v0_signature_hash_raw( + 0, + p2pkh_script(signer.pubkey_hash()).as_bytes(), + prevout.value, + 1, + ) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, high_s); + tx.inputs[0].witness = Witness::from_stack(vec![sig_item, signer.compressed_pubkey()]); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); + } +} + +#[test] +fn roundtrip_nested_segwit() { + let signer = Signer::new(11); + let mut redeem = vec![0x00, 0x14]; + redeem.extend_from_slice(&signer.pubkey_hash()); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: { + let mut script = vec![0xa9, 0x14]; + script.extend_from_slice(&super::hash160_20(&redeem)); + script.push(0x87); + Script::from_bytes(script) + }, + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of( + &prev_tx, + Script::from_bytes(super::push_data(&redeem)), + Witness::new(), + ); + let digest = SighashCache::new(&tx) + .segwit_v0_signature_hash_raw( + 0, + p2pkh_script(signer.pubkey_hash()).as_bytes(), + prevout.value, + 1, + ) + .unwrap(); + let sig_item = signer.ecdsa_item(digest, false); + tx.inputs[0].witness = Witness::from_stack(vec![sig_item, signer.compressed_pubkey()]); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 5); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn roundtrip_taproot_key_path() { + let secp = Secp256k1::new(); + let secret = SecretKey::from_slice(&[13_u8; 32]).unwrap(); + let keypair = Keypair::from_secret_key(&secp, &secret); + let (xonly, _parity) = keypair.x_only_public_key(); + let mut script = vec![0x51, 0x20]; + script.extend_from_slice(&xonly.serialize()); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: Script::from_bytes(script), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .taproot_signature_hash(0, &[prevout], None, None, Sighash::Default) + .unwrap(); + let message = Message::from_digest(*digest.as_byte_array()); + let signature = secp.sign_schnorr(&message, &keypair); + tx.inputs[0].witness = Witness::from_stack(vec![signature[..].to_vec()]); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn roundtrip_taproot_script_path_uses_uncompressed_envelope() { + let mut script = vec![0x51, 0x20]; + script.extend_from_slice(&[3_u8; 32]); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: Script::from_bytes(script), + }; + let prev_tx = funding_tx(prevout); + let tx = spend_of( + &prev_tx, + Script::new(), + Witness::from_stack(vec![vec![4_u8; 64], vec![0x51, 0x60], vec![5_u8; 33]]), + ); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +fn taproot_nonempty_scriptsig_falls_back_to_uncompressed_envelope() { + // Decompression rebuilds an empty scriptSig for P2TR, so a key-path + // witness paired with a non-empty scriptSig must not take the + // compressed path: it has no round trip. It falls back to the + // uncompressed envelope and survives the round trip byte-for-byte. + let mut script = vec![0x51, 0x20]; + script.extend_from_slice(&[9_u8; 32]); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: Script::from_bytes(script), + }; + let prev_tx = funding_tx(prevout.clone()); + let kind = super::prevout_kind(prevout.script_pubkey.as_bytes()); + // Positive control: the exact key-path shape still compresses. + let canonical = spend_of( + &prev_tx, + Script::new(), + Witness::from_stack(vec![vec![7_u8; 64]]), + ); + assert!( + super::compressed_signature(&canonical.inputs[0], &kind).is_some(), + "empty-scriptSig key-path witness must take the compressed path" + ); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&canonical, &mut resolver, true); + + // The same witness with a non-empty scriptSig falls back instead. + let tx = spend_of( + &prev_tx, + Script::from_bytes(vec![0x51]), + Witness::from_stack(vec![vec![7_u8; 64]]), + ); + assert!( + super::compressed_signature(&tx.inputs[0], &kind).is_none(), + "P2TR input with a non-empty scriptSig must fall back to the uncompressed envelope" + ); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); +} + +#[test] +#[allow( + clippy::too_many_lines, + reason = "one table of output shapes exercised in a single round trip" +)] +fn roundtrip_p2sh_multisig_and_output_shapes() { + // P2SH multisig prevout: signature compression must not trigger. + let signer = Signer::new(15); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: { + let mut script = vec![0xa9, 0x14]; + script.extend_from_slice(&[8_u8; 20]); + script.push(0x87); + Script::from_bytes(script) + }, + }; + let prev_tx = funding_tx(prevout); + let mut script_sig = vec![0x00]; + script_sig.extend_from_slice(&super::push_data( + &signer.ecdsa_item(Hash256::default(), false), + )); + script_sig.extend_from_slice(&super::push_data(&[0x52, 0x21])); + let tx = spend_of(&prev_tx, Script::from_bytes(script_sig), Witness::new()); + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx, 0); + roundtrip(&tx, &mut resolver, true); + + // Every output shape, nonzero locktime, all sequence spellings. + let mut multi = Tx { + version: 3, + inputs: vec![ + TxIn { + previous_output: OutPoint::new(Txid(Hash256::from_le_bytes(&[1_u8; 32])), 0), + sequence: Sequence::from_consensus(0), + ..TxIn::default() + }, + TxIn { + previous_output: OutPoint::new(Txid(Hash256::from_le_bytes(&[2_u8; 32])), 1), + sequence: Sequence::from_consensus(0xffff_fffe), + ..TxIn::default() + }, + TxIn { + previous_output: OutPoint::new(Txid(Hash256::from_le_bytes(&[3_u8; 32])), 2), + sequence: Sequence::from_consensus(0xffff_ffff), + ..TxIn::default() + }, + TxIn { + previous_output: OutPoint::new(Txid(Hash256::from_le_bytes(&[4_u8; 32])), 3), + sequence: Sequence::from_consensus(0x1234_5678), + script_sig: Script::from_bytes(vec![0x51]), + witness: Witness::from_stack(vec![vec![9_u8; 3]]), + }, + ], + outputs: vec![ + TxOut { + value: Amount::from_sat(1), + script_pubkey: Script::from_bytes(vec![0x6a, 0x02, 0xaa, 0xbb]), + }, + TxOut { + value: Amount::from_sat(2), + script_pubkey: Script::from_bytes({ + let mut s = vec![0x41]; + s.extend_from_slice(&[5_u8; 65]); + s.push(0xac); + s + }), + }, + TxOut { + value: Amount::from_sat(3), + script_pubkey: Script::from_bytes({ + let mut s = vec![0x21]; + s.extend_from_slice(&[5_u8; 33]); + s.push(0xac); + s + }), + }, + TxOut { + value: Amount::from_sat(4), + script_pubkey: p2pkh_script([6_u8; 20]), + }, + TxOut { + value: Amount::from_sat(5), + script_pubkey: Script::from_bytes({ + let mut s = vec![0xa9, 0x14]; + s.extend_from_slice(&[6_u8; 20]); + s.push(0x87); + s + }), + }, + TxOut { + value: Amount::from_sat(6), + script_pubkey: p2wpkh_script([6_u8; 20]), + }, + TxOut { + value: Amount::from_sat(7), + script_pubkey: Script::from_bytes({ + let mut s = vec![0x00, 0x20]; + s.extend_from_slice(&[6_u8; 32]); + s + }), + }, + TxOut { + value: Amount::from_sat(8), + script_pubkey: Script::from_bytes({ + let mut s = vec![0x51, 0x20]; + s.extend_from_slice(&[6_u8; 32]); + s + }), + }, + ], + lock_time: LockTime::from_consensus(800_000), + }; + let mut resolver = MapResolver::default(); + let compressed = roundtrip(&multi, &mut resolver, false); + assert!(compressed.len() < consensus_bytes(&multi).len()); + multi.lock_time = LockTime::from_consensus(1_700_000_000); + roundtrip(&multi, &mut resolver, false); +} + +#[test] +fn minimum_height_sentinel_and_locktime_only_candidate_roundtrip() { + // No locktime and no compressible coin: the u32::MAX "no candidate" + // sentinel encodes as zero, so the minimum-height presence bit (bit + // 7 of the metadata byte) stays clear and offsets never appear. + let tx = funding_tx(TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2wpkh_script([2_u8; 20]), + }); + let mut resolver = MapResolver::default(); + let (compressed, _) = compress_transaction( + &tx, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert_eq!(compressed[0] & 0x80, 0, "sentinel must encode as zero"); + let restored = decompress_transaction(&compressed, &mut resolver).unwrap(); + assert_eq!(restored, tx); + + // Locktime alone supplies the candidate: minimum height is + // locktime - 1 and the stored locktime is the checked difference 1. + let mut locked = tx.clone(); + locked.lock_time = LockTime::from_consensus(500_000); + let (compressed, _) = compress_transaction( + &locked, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert_eq!(compressed[0] & 0x80, 0x80, "locktime candidate must encode"); + assert_eq!( + compressed[0] & 0x40, + 0x40, + "locktime presence bit must be set" + ); + let restored = decompress_transaction(&compressed, &mut resolver).unwrap(); + assert_eq!(restored, locked); +} + +#[test] +fn missing_context_degrades_compress_but_refuses_decompress() { + let signer = Signer::new(17); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2wpkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .segwit_v0_signature_hash_raw( + 0, + p2pkh_script(signer.pubkey_hash()).as_bytes(), + prevout.value, + 1, + ) + .unwrap(); + tx.inputs[0].witness = Witness::from_stack(vec![ + signer.ecdsa_item(digest, false), + signer.compressed_pubkey(), + ]); + + // Compress with no context: raw outpoint plus exactly one warning. + let mut empty = MapResolver::default(); + let (compressed, warnings) = compress_transaction( + &tx, + &mut empty, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].starts_with("UTXO(0): "), "{warnings:?}"); + // The signature stayed uncompressed (prevout kind unknown), so the + // same empty resolver can still decompress. + let restored = decompress_transaction(&compressed, &mut empty).unwrap(); + assert_eq!(restored, tx); + + // Compress with context (compressed signature + outpoint), then + // decompress without it: refusal, never a fabricated prevout. + let mut full = MapResolver::default(); + full.register_tx(100, &prev_tx, 0); + let (compressed, warnings) = compress_transaction( + &tx, + &mut full, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert_eq!(warnings, Vec::::new()); + let mut empty = MapResolver::default(); + let error = decompress_transaction(&compressed, &mut empty).unwrap_err(); + assert!( + matches!(error, CodecError::BodyMissing(_)), + "compressed outpoint without its block: {error:?}" + ); +} + +/// Production resolver integration: a Context whose block log and body +/// source serve one crafted block must compress the spend's outpoint +/// (no warning) and restore it through the handlers. Exercises +/// `RpcPrevoutResolver` against `block_by_height_in_view` and the body +/// source directly, not a codec fake. +#[test] +fn production_resolver_resolves_through_block_bodies() { + use std::sync::Arc; + + use bitcoin_rs_chain::BlockBodySource; + use sonic_rs::json; + + use crate::context::Context; + use crate::handlers::tx::{compressrawtransaction, decompressrawtransaction}; + + struct StubBody(Vec); + impl BlockBodySource for StubBody { + fn block_body( + &self, + height: u32, + _hash: bitcoin_rs_primitives::BlockHash, + ) -> Result>, bitcoin_rs_chain::BlockBodyReadError> { + Ok((height == 100).then(|| self.0.clone())) + } + } + + /// Height-only txindex: the resolver must walk from this height to + /// the body source for the prevout itself. + struct StubTxIndex(Txid); + impl bitcoin_rs_index::DerivedIndexQuery for StubTxIndex { + fn transaction(&self, _txid: &Txid) -> Result, bitcoin_rs_index::TxQueryError> { + Ok(None) + } + fn outpoint_value( + &self, + _outpoint: &OutPoint, + ) -> Result, bitcoin_rs_index::TxQueryError> { + Ok(None) + } + fn transaction_height( + &self, + txid: &Txid, + ) -> Result, bitcoin_rs_index::TxQueryError> { + Ok((self.0 == *txid).then_some(100)) + } + fn index_info( + &self, + ) -> Result { + Ok(bitcoin_rs_index::DerivedIndexInfo { + synced: true, + best_block_height: 100, + }) + } + } + + let signer = Signer::new(19); + let prevout = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2wpkh_script(signer.pubkey_hash()), + }; + let prev_tx = funding_tx(prevout.clone()); + let mut tx = spend_of(&prev_tx, Script::new(), Witness::new()); + let digest = SighashCache::new(&tx) + .segwit_v0_signature_hash_raw( + 0, + p2pkh_script(signer.pubkey_hash()).as_bytes(), + prevout.value, + 1, + ) + .unwrap(); + tx.inputs[0].witness = Witness::from_stack(vec![ + signer.ecdsa_item(digest, false), + signer.compressed_pubkey(), + ]); + + let mut block = bitcoin_rs_primitives::Network::Regtest.genesis_block(); + block.txs = vec![prev_tx.clone()]; + let body = consensus_bytes(&block); + + let mut ctx = Context::new(); + ctx.compressed_transactions = true; + ctx.chain.block_body_source = Some(Arc::new(StubBody(body))); + ctx.indexes.derived_index = Some(Arc::new(StubTxIndex(prev_tx.txid()))); + ctx.chain + .add_block(bitcoin_rs_index::block_log::BlockRecord { + hash: bitcoin_rs_primitives::BlockHash::from( + bitcoin_rs_primitives::Hash256::from_le_bytes(&[9_u8; 32]), + ), + height: 100, + body_size: 0, + header: None, + tx_count: 1, + time: 0, + }); + let ctx = Arc::new(ctx); + let raw_hex = bitcoin::hex::DisplayHex::to_lower_hex_string(&consensus_bytes(&tx)); + let latch = bitcoin_rs_chain::LatchReader::fixture_never(); + let compressed = compressrawtransaction(&ctx, &json!([raw_hex]), &latch).unwrap(); + assert!( + compressed + .get("warnings") + .and_then(Value::as_array) + .is_some_and(sonic_rs::Array::is_empty), + "body-backed resolver must leave no warning: {compressed}" + ); + let compressed_hex = compressed + .get("result") + .and_then(Value::as_str) + .unwrap() + .to_owned(); + let restored = decompressrawtransaction(&ctx, &json!([compressed_hex]), &latch).unwrap(); + assert_eq!(restored.as_str().unwrap(), raw_hex); +} + +#[test] +fn malformed_streams_are_rejected() { + let vector = &fixture_vectors()[0]; + let compressed = hex_bytes( + vector + .get("compressed_hex") + .and_then(Value::as_str) + .unwrap(), + ); + let mut resolver = fixture_resolver(vector); + + // Truncated. + let error = + decompress_transaction(&compressed[..compressed.len() - 3], &mut resolver).unwrap_err(); + assert_eq!(error, CodecError::Truncated); + // Trailing bytes. + let mut trailed = compressed.clone(); + trailed.push(0); + let error = decompress_transaction(&trailed, &mut resolver).unwrap_err(); + assert_eq!(error, CodecError::NonCanonical); + // Set padding bit in the metadata packing (byte 5 low bits are pad). + let mut padded = compressed.clone(); + padded[5] |= 0x01; + let error = decompress_transaction(&padded, &mut resolver).unwrap_err(); + assert_eq!(error, CodecError::NonCanonical); + // Corrupt one ECDSA signature byte in the P2WPKH vector: recovery no + // longer matches the prevout commitment. + let wpkh = &fixture_vectors()[1]; + let wpkh_compressed = hex_bytes(wpkh.get("compressed_hex").and_then(Value::as_str).unwrap()); + let mut wpkh_resolver = fixture_resolver(wpkh); + let mut forged = wpkh_compressed; + forged[20] ^= 0xff; + let error = decompress_transaction(&forged, &mut wpkh_resolver).unwrap_err(); + assert_eq!(error, CodecError::SignatureUnrecoverable); + // Empty input. + let error = decompress_transaction(&[], &mut resolver).unwrap_err(); + assert_eq!(error, CodecError::Truncated); + // Cancellation. + resolver.cancelled = true; + let error = decompress_transaction(&compressed, &mut resolver).unwrap_err(); + assert_eq!(error, CodecError::Cancelled); +} + +/// Resolver backed directly by downloaded mainnet bodies, used only by +/// the ignored truth test. Indexes are rebuilt from the bodies on every +/// run, so a stale fixture cannot pass against itself. +#[cfg(test)] +struct BodyResolver { + heights: HashMap, // txid -> (height, tx index, first flat, output count) + txouts: HashMap, + at: HashMap<(u32, u32), OutPoint>, +} + +#[cfg(test)] +impl BodyResolver { + fn from_bodies(dir: &std::path::Path) -> Self { + let mut resolver = Self { + heights: HashMap::new(), + txouts: HashMap::new(), + at: HashMap::new(), + }; + let mut entries: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "bin")) + .collect(); + entries.sort(); + for path in entries { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let (height, hash) = name.split_once('-').unwrap(); + let height: u32 = height.parse().unwrap(); + let bytes = std::fs::read(&path).unwrap(); + // The filename hash must be the body's double-SHA256 header hash. + let digest = { + use sha2::{Digest as _, Sha256}; + let first = Sha256::digest(&bytes[..80]); + Sha256::digest(first) + }; + let mut displayed = digest.to_vec(); + displayed.reverse(); + assert_eq!( + bitcoin::hex::DisplayHex::to_lower_hex_string(&displayed), + hash.trim_end_matches(".bin"), + "body file hash mismatch: {name}" + ); + let block = ParsedBlock::parse_exact(&bytes).unwrap(); + let mut flat = 0_u32; + for (tx_index, parsed) in block.transactions().iter().enumerate() { + let tx = parsed.materialize(); + resolver.heights.insert( + tx.txid(), + ( + height, + u32::try_from(tx_index).unwrap(), + flat, + u32::try_from(tx.outputs.len()).unwrap(), + ), + ); + for (vout, output) in tx.outputs.iter().enumerate() { + let vout = u32::try_from(vout).unwrap(); + let outpoint = OutPoint::new(tx.txid(), vout); + resolver.txouts.insert(outpoint, (output.clone(), height)); + resolver.at.insert((height, flat + vout), outpoint); + } + flat += u32::try_from(tx.outputs.len()).unwrap(); + } + } + resolver + } +} + +#[cfg(test)] +impl PrevoutResolver for BodyResolver { + fn prevout(&mut self, outpoint: &OutPoint) -> Result, LookupError> { + Ok(self + .txouts + .get(outpoint) + .map(|(txout, height)| ResolvedPrevout { + txout: txout.clone(), + confirmed_height: Some(*height), + })) + } + + fn flat_output_index( + &mut self, + height: u32, + txid: &Txid, + vout: u32, + ) -> Result, LookupError> { + Ok(match self.heights.get(txid) { + Some((h, _index, first, count)) if *h == height && vout < *count => Some(first + vout), + _ => None, + }) + } + + fn outpoint_at( + &mut self, + height: u32, + flat_index: u32, + ) -> Result, LookupError> { + Ok(self.at.get(&(height, flat_index)).map(|outpoint| { + let (txout, _) = self.txouts.get(outpoint).unwrap(); + ( + *outpoint, + ResolvedPrevout { + txout: txout.clone(), + confirmed_height: Some(height), + }, + ) + })) + } + + fn cancelled(&self) -> bool { + false + } +} + +/// Truth loop over the downloaded mainnet bodies: re-derives every +/// fixture field, then compresses and decompresses every published +/// vector against body-derived context, reporting ratio and cost per +/// representative type. Absent bodies are BLOCKED, not a pass. +#[test] +#[ignore = "requires BITCOIN_RS_BIP337_MAINNET_BODIES with mainnet block bodies"] +#[allow( + clippy::too_many_lines, + reason = "one derivation walk over every fixture field and vector" +)] +fn mainnet_bodies_truth() { + let Ok(dir) = std::env::var("BITCOIN_RS_BIP337_MAINNET_BODIES") else { + panic!( + "BLOCKED: BITCOIN_RS_BIP337_MAINNET_BODIES is unset; download mainnet bodies \ + 833265..833280 to run the BIP337 fixture truth loop" + ); + }; + let resolver = BodyResolver::from_bodies(std::path::Path::new(&dir)); + for vector in fixture_vectors() { + let name = vector + .get("name") + .and_then(Value::as_str) + .unwrap() + .to_owned(); + let raw: Tx = deserialize(&hex_bytes( + vector.get("raw_hex").and_then(Value::as_str).unwrap(), + )) + .unwrap(); + assert_eq!( + raw.txid().to_string(), + vector.get("txid").and_then(Value::as_str).unwrap() + ); + assert_eq!( + raw.wtxid().to_string(), + vector.get("wtxid").and_then(Value::as_str).unwrap() + ); + for input in vector.get("inputs").and_then(Value::as_array).unwrap() { + let prevout_txid = + Txid::from_str(input.get("prevout_txid").and_then(Value::as_str).unwrap()).unwrap(); + let (height, tx_index, first_flat, _count) = + resolver.heights.get(&prevout_txid).unwrap(); + assert_eq!( + *tx_index, + u32::try_from( + input + .get("tx_index_in_block") + .and_then(Value::as_u64) + .unwrap() + ) + .unwrap(), + "{name} tx index in block" + ); + let vout = u32::try_from(input.get("vout").and_then(Value::as_u64).unwrap()).unwrap(); + let (txout, txout_height) = resolver + .txouts + .get(&OutPoint::new(prevout_txid, vout)) + .unwrap(); + assert_eq!( + *height, + u32::try_from(input.get("height").and_then(Value::as_u64).unwrap()).unwrap(), + "{name} height" + ); + assert_eq!(*txout_height, *height, "{name} txout height"); + assert_eq!( + *first_flat, + u32::try_from( + input + .get("cumulative_outputs_before") + .and_then(Value::as_u64) + .unwrap() + ) + .unwrap(), + "{name} cumulative outputs before" + ); + assert_eq!( + first_flat + vout, + u32::try_from( + input + .get("flat_output_index") + .and_then(Value::as_u64) + .unwrap() + ) + .unwrap(), + "{name} flat index" + ); + assert_eq!( + txout.script_pubkey.as_bytes(), + hex_bytes( + input + .get("prevout_script_hex") + .and_then(Value::as_str) + .unwrap() + ) + .as_slice(), + "{name} prevout script" + ); + assert_eq!( + txout.value.to_sat(), + input.get("value_sats").and_then(Value::as_u64).unwrap(), + "{name} value" + ); + } + let mut compress_resolver = BodyResolver::from_bodies(std::path::Path::new(&dir)); + let start = std::time::Instant::now(); + let mut compressed = Vec::new(); + for _ in 0..200 { + let (bytes, warnings) = compress_transaction( + &raw, + &mut compress_resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert!(warnings.is_empty(), "{name}: {warnings:?}"); + compressed = bytes; + } + let encode_elapsed = start.elapsed(); + assert_eq!( + compressed, + hex_bytes( + vector + .get("compressed_hex") + .and_then(Value::as_str) + .unwrap() + ), + "{name} compressed bytes" + ); + let start = std::time::Instant::now(); + let mut restored = raw.clone(); + for _ in 0..200 { + restored = decompress_transaction(&compressed, &mut compress_resolver).unwrap(); + } + let decode_elapsed = start.elapsed(); + assert_eq!(restored, raw, "{name} restored"); + eprintln!( + "bip337 {name}: raw={} compressed={} ratio_permille={} encode_us_per={:.1} decode_us_per={:.1}", + consensus_bytes(&raw).len(), + compressed.len(), + compressed.len() * 1000 / consensus_bytes(&raw).len(), + encode_elapsed.as_secs_f64() * 1e6 / 200.0, + decode_elapsed.as_secs_f64() * 1e6 / 200.0, + ); + } +} + +#[test] +fn shared_sighash_cache_recovers_every_input() { + // One cache over the immutable skeleton must serve every compressed + // input: the legacy input and the segwit input below share the same + // transaction midstates, so rebuilding the cache per input repeats + // identical hashing work. + let signer_a = Signer::new(31); + let signer_b = Signer::new(32); + let prevout_a = TxOut { + value: Amount::from_sat(50_000), + script_pubkey: p2pkh_script(signer_a.pubkey_hash()), + }; + let prevout_b = TxOut { + value: Amount::from_sat(60_000), + script_pubkey: p2wpkh_script(signer_b.pubkey_hash()), + }; + let prev_tx_a = funding_tx(prevout_a.clone()); + let prev_tx_b = funding_tx(prevout_b.clone()); + + // Skeleton first: sighashes commit to outpoints, sequences, outputs, + // and locktime, never to the authorizations being reconstructed. + let mut tx = Tx { + version: 2, + inputs: vec![ + TxIn { + previous_output: OutPoint::new(prev_tx_a.txid(), 0), + script_sig: Script::new(), + sequence: Sequence::from_consensus(0xffff_fffd), + witness: Witness::new(), + }, + TxIn { + previous_output: OutPoint::new(prev_tx_b.txid(), 0), + script_sig: Script::new(), + sequence: Sequence::from_consensus(0xffff_fffd), + witness: Witness::new(), + }, + ], + outputs: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: Script::from_bytes( + vec![0x00, 0x14].into_iter().chain([7_u8; 20]).collect(), + ), + }], + lock_time: LockTime::ZERO, + }; + let digest_a = SighashCache::new(&tx) + .legacy_signature_hash(0, prevout_a.script_pubkey.as_bytes(), 1) + .unwrap(); + let digest_b = SighashCache::new(&tx) + .segwit_v0_signature_hash_raw( + 1, + p2pkh_script(signer_b.pubkey_hash()).as_bytes(), + prevout_b.value, + 1, + ) + .unwrap(); + let sig_item_a = signer_a.ecdsa_item(digest_a, false); + let mut script_sig = super::push_data(&sig_item_a); + script_sig.extend_from_slice(&super::push_data(&signer_a.compressed_pubkey())); + tx.inputs[0].script_sig = Script::from_bytes(script_sig); + let sig_item_b = signer_b.ecdsa_item(digest_b, false); + tx.inputs[1].witness = Witness::from_stack(vec![sig_item_b, signer_b.compressed_pubkey()]); + + // The shared cache pins the new call shape at compile time: one + // `SighashCache` borrowed alongside both recovery requests. The + // transaction carries DER signatures plus a sighash byte; recovery + // needs the 64-byte compact form, so strip the trailing byte and + // parse the DER prefix. + fn compact_of(der_plus_hashtype: &[u8]) -> [u8; 64] { + let parsed = bitcoin::secp256k1::ecdsa::Signature::from_der( + &der_plus_hashtype[..der_plus_hashtype.len() - 1], + ) + .unwrap(); + parsed.serialize_compact() + } + let cache = SighashCache::new(&tx); + let compact_a = compact_of(&sig_item_a); + let compact_b = compact_of(&tx.inputs[1].witness[0]); + let kind_a = super::prevout_kind(prevout_a.script_pubkey.as_bytes()); + let kind_b = super::prevout_kind(prevout_b.script_pubkey.as_bytes()); + let resolved_a = ResolvedPrevout { + txout: prevout_a.clone(), + confirmed_height: Some(100), + }; + let resolved_b = ResolvedPrevout { + txout: prevout_b.clone(), + confirmed_height: Some(101), + }; + let recovered_a = super::recover_pubkey( + &cache, + &super::Recovery { + index: 0, + sig: &compact_a, + hash_type: 1, + kind: &kind_a, + prevout: &resolved_a, + stored_pkh: None, + }, + ) + .unwrap(); + assert_eq!(recovered_a, signer_a.compressed_pubkey()); + let recovered_b = super::recover_pubkey( + &cache, + &super::Recovery { + index: 1, + sig: &compact_b, + hash_type: 1, + kind: &kind_b, + prevout: &resolved_b, + stored_pkh: None, + }, + ) + .unwrap(); + assert_eq!(recovered_b, signer_b.compressed_pubkey()); + + // End to end: both inputs compress and the split prepare/finalize + // path (which now builds that single cache) restores the pair. + let mut resolver = MapResolver::default(); + resolver.register_tx(100, &prev_tx_a, 0); + resolver.register_tx(101, &prev_tx_b, 0); + let (compressed, warnings) = compress_transaction( + &tx, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let plan = super::decompress_prepare(&compressed, &mut resolver).unwrap(); + assert_eq!(plan.inputs.len(), 2); + let restored = super::decompress_finalize(plan, &resolver).unwrap(); + assert_eq!(&restored, &tx); + assert_eq!( + &super::decompress_transaction(&compressed, &mut resolver).unwrap(), + &tx + ); +} + +#[test] +fn production_resolver_clones_only_the_requested_output() { + use std::sync::Arc; + + use bitcoin_rs_chain::BlockBodySource; + + use crate::context::Context; + + struct StubBody(Vec); + impl BlockBodySource for StubBody { + fn block_body( + &self, + height: u32, + _hash: bitcoin_rs_primitives::BlockHash, + ) -> Result>, bitcoin_rs_chain::BlockBodyReadError> { + Ok((height == 100).then(|| self.0.clone())) + } + } + + struct StubTxIndex(Txid); + impl bitcoin_rs_index::DerivedIndexQuery for StubTxIndex { + fn transaction(&self, _txid: &Txid) -> Result, bitcoin_rs_index::TxQueryError> { + Ok(None) + } + fn outpoint_value( + &self, + _outpoint: &OutPoint, + ) -> Result, bitcoin_rs_index::TxQueryError> { + Ok(None) + } + fn transaction_height( + &self, + txid: &Txid, + ) -> Result, bitcoin_rs_index::TxQueryError> { + Ok((self.0 == *txid).then_some(100)) + } + fn index_info( + &self, + ) -> Result { + Ok(bitcoin_rs_index::DerivedIndexInfo { + synced: true, + best_block_height: 100, + }) + } + } + + // One funding transaction with three distinct outputs: resolving or + // locating the middle one must return exactly that output, proving + // the resolver cloned the requested `TxOut` rather than the whole + // funding transaction. + let funding = Tx { + version: 2, + inputs: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: Script::from_bytes(vec![0x01, 0x2a]), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + outputs: vec![ + TxOut { + value: Amount::from_sat(11_000), + script_pubkey: p2pkh_script([1_u8; 20]), + }, + TxOut { + value: Amount::from_sat(22_000), + script_pubkey: p2wpkh_script([2_u8; 20]), + }, + TxOut { + value: Amount::from_sat(33_000), + script_pubkey: p2pkh_script([3_u8; 20]), + }, + ], + lock_time: LockTime::ZERO, + }; + let txid = funding.txid(); + + let mut block = bitcoin_rs_primitives::Network::Regtest.genesis_block(); + block.txs = vec![funding.clone()]; + let body = consensus_bytes(&block); + + let mut ctx = Context::new(); + ctx.chain.block_body_source = Some(Arc::new(StubBody(body))); + ctx.indexes.derived_index = Some(Arc::new(StubTxIndex(txid))); + ctx.chain + .add_block(bitcoin_rs_index::block_log::BlockRecord { + hash: bitcoin_rs_primitives::BlockHash::from( + bitcoin_rs_primitives::Hash256::from_le_bytes(&[9_u8; 32]), + ), + height: 100, + body_size: 0, + header: None, + tx_count: 1, + time: 0, + }); + let ctx = Arc::new(ctx); + let latch = bitcoin_rs_chain::LatchReader::fixture_never(); + let mut resolver = crate::context::RpcPrevoutResolver::new(&ctx, latch).unwrap(); + + // `prevout` resolves each vout to its own output. + for (vout, expected) in funding.outputs.iter().enumerate() { + let vout = u32::try_from(vout).unwrap(); + let resolved = resolver + .prevout(&OutPoint::new(txid, vout)) + .unwrap() + .expect("funding output must resolve"); + assert_eq!(&resolved.txout, expected, "vout {vout} must match"); + assert_eq!(resolved.confirmed_height, Some(100)); + } + // The middle output differs from its neighbours: a whole-transaction + // mix-up would surface here, not just a missing-output failure. + let middle = resolver.prevout(&OutPoint::new(txid, 1)).unwrap().unwrap(); + assert_eq!(middle.txout, funding.outputs[1]); + assert_ne!(middle.txout, funding.outputs[0]); + assert_ne!(middle.txout, funding.outputs[2]); + + // `outpoint_at` is the flat-index inverse and returns the same TxOuts. + assert_eq!(resolver.flat_output_index(100, &txid, 1).unwrap(), Some(1)); + for flat in [0_u32, 1, 2] { + let (outpoint, resolved) = resolver + .outpoint_at(100, flat) + .unwrap() + .expect("flat index must locate an output"); + let vout = flat; + assert_eq!(outpoint, OutPoint::new(txid, vout)); + assert_eq!( + resolved.txout, + funding.outputs[usize::try_from(vout).unwrap()] + ); + assert_eq!(resolved.confirmed_height, Some(100)); + } + + // Proved-absent stays `None` on both paths, never an error. + assert!(resolver.prevout(&OutPoint::new(txid, 9)).unwrap().is_none()); + assert!(resolver.outpoint_at(100, 9).unwrap().is_none()); +} + +/// Counts body reads per height so tests prove each body loads once. +struct CountingBodies { + bodies: HashMap>, + loads: std::sync::Mutex>, +} + +impl bitcoin_rs_chain::BlockBodySource for CountingBodies { + fn block_body( + &self, + height: u32, + _hash: bitcoin_rs_primitives::BlockHash, + ) -> Result>, bitcoin_rs_chain::BlockBodyReadError> { + if let Some(body) = self.bodies.get(&height) { + *self.loads.lock().unwrap().entry(height).or_default() += 1; + Ok(Some(body.clone())) + } else { + Ok(None) + } + } +} + +/// Builds one single-output funding block per `heights` entry, wires the +/// records plus a counting body source into a fresh [`Context`], and funds +/// the UTXO set so the compress pass resolves prevouts without body reads. +/// Returns the context, the source, and the funding txids in height order. +fn cycling_fixture( + heights: &[u32], +) -> ( + crate::context::Context, + std::sync::Arc, + Vec, +) { + use std::sync::Arc; + + let mut ctx = crate::context::Context::new(); + let mut bodies = HashMap::new(); + let mut txids = Vec::new(); + let mut changes = bitcoin_rs_utxo::contract::BlockChanges::default(); + for (slot, &height) in heights.iter().enumerate() { + let funding = funding_tx(TxOut { + value: Amount::from_sat(10_000), + script_pubkey: Script::from_bytes(vec![0x6a, u8::try_from(slot).unwrap()]), + }); + let txid = funding.txid(); + let mut block = bitcoin_rs_primitives::Network::Regtest.genesis_block(); + block.txs = vec![funding.clone()]; + bodies.insert(height, consensus_bytes(&block)); + changes.add(bitcoin_rs_utxo::contract::UtxoAdd::new( + OutPoint::new(txid, 0), + funding.outputs[0].clone(), + false, + height, + )); + ctx.chain + .add_block(bitcoin_rs_index::block_log::BlockRecord { + hash: bitcoin_rs_primitives::BlockHash::from( + bitcoin_rs_primitives::Hash256::from_le_bytes( + &[u8::try_from(height).unwrap(); 32], + ), + ), + height, + body_size: 0, + header: None, + tx_count: 1, + time: 0, + }); + txids.push(txid); + } + bitcoin_rs_utxo::contract::commit_block_changes( + &ctx.chain.utxo.fixture_set(), + &changes, + &bitcoin_rs_primitives::Hash256::default(), + ) + .unwrap(); + let source = Arc::new(CountingBodies { + bodies, + loads: std::sync::Mutex::new(HashMap::new()), + }); + let object: Arc = source.clone(); + ctx.chain.block_body_source = Some(object); + (ctx, source, txids) +} + +/// Builds a spender with `inputs` inputs round-robining `txids` (vout 0), +/// empty authorizations, and one change output. +fn cycling_spend(txids: &[Txid], inputs: usize) -> Tx { + Tx { + version: 2, + inputs: txids + .iter() + .cycle() + .take(inputs) + .map(|txid| TxIn { + previous_output: OutPoint::new(*txid, 0), + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }) + .collect(), + outputs: vec![TxOut { + value: Amount::from_sat(40_000), + script_pubkey: p2wpkh_script([7_u8; 20]), + }], + lock_time: LockTime::ZERO, + } +} + +fn assert_loads(source: &CountingBodies, heights: &[u32], per_height: usize) { + let loads = source.loads.lock().unwrap(); + assert_eq!(loads.len(), heights.len(), "{loads:?}"); + let mut total = 0_usize; + for height in heights { + let count = loads.get(height).copied().unwrap_or(0); + assert_eq!(count, per_height, "height {height}"); + total += count; + } + assert_eq!(total, heights.len() * per_height); +} + +/// Five heights cycling through the four-body cache: grouped resolution +/// loads each body once instead of paying a full body read, parse, +/// materialization, and txid pass per input inside the stable-chainstate +/// exclusion. Ten inputs round-robin five heights; the counting source +/// proves five total loads (one per height), and both codec passes +/// round-trip the spending transaction. +#[test] +fn five_heights_cycling_loads_each_body_once() { + use std::sync::Arc; + + let heights: Vec = (100..105).collect(); + let (ctx, source, txids) = cycling_fixture(&heights); + let ctx = Arc::new(ctx); + let spend = cycling_spend(&txids, 10); + + // Compress: prevouts hit the UTXO set, so all five body loads happen in + // the grouped flat-index pass — one per height despite round-robin inputs. + let latch = bitcoin_rs_chain::LatchReader::fixture_never(); + let mut resolver = crate::context::RpcPrevoutResolver::new(&ctx, latch).unwrap(); + let (compressed, warnings) = compress_transaction( + &spend, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_loads(&source, &heights, 1); + + // Decompress resolves the same five heights grouped: one load each, + // restoring the exact spending transaction. + let latch = bitcoin_rs_chain::LatchReader::fixture_never(); + let mut resolver = crate::context::RpcPrevoutResolver::new(&ctx, latch).unwrap(); + let plan = super::decompress_prepare(&compressed, &mut resolver).unwrap(); + let restored = super::decompress_finalize(plan, &resolver).unwrap(); + assert_eq!(&restored, &spend); + assert_loads(&source, &heights, 2); +} + +/// Past the grouped handful, the cumulative body-load budget fails closed: +/// ten distinct heights need ten loads against a budget of eight, so the +/// call refuses with `LimitExceeded` after bounded work instead of +/// materializing every body under the exclusion. +#[test] +fn cumulative_body_budget_fails_closed_past_grouped_heights() { + use std::sync::Arc; + + let heights: Vec = (100..110).collect(); + let (ctx, source, txids) = cycling_fixture(&heights); + let ctx = Arc::new(ctx); + let spend = cycling_spend(&txids, 10); + + let latch = bitcoin_rs_chain::LatchReader::fixture_never(); + let mut resolver = crate::context::RpcPrevoutResolver::new(&ctx, latch).unwrap(); + let error = compress_transaction( + &spend, + &mut resolver, + CompressOptions { + compress_outpoints: true, + }, + ) + .unwrap_err(); + assert_eq!(error, CodecError::LimitExceeded(super::Limit::Loads)); + // Bounded work: eight loads happened; the ninth failed before body I/O. + let loads = source.loads.lock().unwrap(); + let total: usize = loads.values().sum(); + assert_eq!(total, super::MAX_CUMULATIVE_BODY_LOADS); +} diff --git a/crates/rpc/src/context.rs b/crates/rpc/src/context.rs index 5a471a59d..b1669bdbb 100644 --- a/crates/rpc/src/context.rs +++ b/crates/rpc/src/context.rs @@ -2,25 +2,36 @@ use alloc::sync::Arc; #[cfg(any(test, feature = "test-seam"))] use arc_swap::ArcSwapOption; use bitcoin_rs_chain::{ - BlockBodySource, BlockTreeReader, LatchReader, TipReader, TipSnapshot, softfork_state, + BlockBodyReadError, BlockBodySource, BlockTreeReader, LatchReader, TipReader, TipSnapshot, + softfork_state, }; use bitcoin_rs_mempool::{ AdmissionChain, BodyInterest, ChainAdmissionSnapshot, MempoolGateway, PrevoutMeta, }; + #[cfg(any(test, feature = "test-seam"))] use bitcoin_rs_mempool::{Mempool, MempoolLimits, MempoolObserver}; use bitcoin_rs_mining::MiningControl; use bitcoin_rs_primitives::{ - BlockHash, CompactTarget, Hash256, Network, OutPoint, Tx, consensus_bytes, unix_time_secs, + BlockHash, CompactTarget, Hash256, Network, OutPoint, Tx, TxIn, TxOut, Txid, consensus_bytes, + layout::ParsedBlock, unix_time_secs, +}; + +use crate::error::RpcError; + +use crate::compressed_tx::{ + LookupError, MAX_CUMULATIVE_BODY_BYTES, MAX_CUMULATIVE_BODY_LOADS, MAX_RESOLVER_STATE_BYTES, + MAX_RETAINED_BODIES, MAX_RETAINED_BODY_BYTES, MAX_RETAINED_BYTES_TOTAL, + MAX_RETAINED_INDEX_BYTES, PrevoutResolver, ResolvedPrevout, }; use bitcoin::hex::DisplayHex as _; #[cfg(any(test, feature = "test-seam"))] use bitcoin_rs_consensus::ValidationEngine; #[cfg(test)] -use bitcoin_rs_primitives::{Amount, Script, Txid}; +use bitcoin_rs_primitives::{Amount, Script}; use core::fmt; -use core::sync::atomic::{AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use hashbrown::HashMap; use parking_lot::Mutex; #[cfg(any(test, feature = "test-seam"))] @@ -336,6 +347,8 @@ pub use bitcoin_rs_index::{ pub struct ContextHandles { /// Enable the compiled experimental keyless tweak service. No wallet state. pub silent_payments: bool, + /// Enable the optional BIP337 compressed-transaction RPCs. No wallet state. + pub compressed_transactions: bool, /// Chain capability: tips, block log, UTXO set, block tree, transition /// barrier, and the chain-owned control surfaces. pub chain: ChainHandles, @@ -563,6 +576,8 @@ pub struct MiningHandles { pub struct Context { #[cfg(feature = "silent-payments")] pub(crate) silent_payments: Option>, + /// Runtime opt-in for the BIP337 compressed-transaction RPCs. + pub(crate) compressed_transactions: bool, /// Chain capability: tips, block log, UTXO set, block tree, transition /// barrier, and the chain-owned control surfaces. pub chain: ChainHandles, @@ -585,6 +600,72 @@ pub struct Context { server_bound_at: Mutex>, /// Limits concurrent full-block and spent-output response materializations. block_render_budget: Arc, + /// Calls currently executing through `Handler::dispatch`, oldest first. + pub(crate) active_calls: ActiveCalls, +} + +/// One in-flight JSON-RPC call, tracked for `getrpcinfo`. +/// +/// Registration happens in `Handler::dispatch` only after registry +/// resolution and blocking-quota acquisition, so unknown or refused +/// methods never appear. Removal is by the unique `id`, never by method +/// name, so two concurrent calls to one method settle independently. +pub(crate) struct ActiveCalls { + entries: Mutex>, + next_id: AtomicU64, +} + +struct ActiveCallEntry { + id: u64, + method: String, + started: Instant, +} + +/// RAII registration of one active call; dropping removes exactly its entry. +pub(crate) struct ActiveCallGuard<'a> { + calls: &'a ActiveCalls, + id: u64, +} + +impl Drop for ActiveCallGuard<'_> { + fn drop(&mut self) { + self.calls + .entries + .lock() + .retain(|entry| entry.id != self.id); + } +} + +impl ActiveCalls { + fn new() -> Self { + Self { + entries: Mutex::new(Vec::new()), + next_id: AtomicU64::new(1), + } + } + + /// Registers `method` as started now and returns its removal guard. + pub(crate) fn begin(&self, method: &str) -> ActiveCallGuard<'_> { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + self.entries.lock().push(ActiveCallEntry { + id, + method: method.to_owned(), + started: Instant::now(), + }); + ActiveCallGuard { calls: self, id } + } + + /// `(method, elapsed_micros)` rows, oldest first. + pub(crate) fn snapshot(&self) -> Vec<(String, u64)> { + self.entries + .lock() + .iter() + .map(|entry| { + let micros = u64::try_from(entry.started.elapsed().as_micros()).unwrap_or(u64::MAX); + (entry.method.clone(), micros) + }) + .collect() + } } impl fmt::Debug for Context { @@ -696,6 +777,7 @@ impl Default for ContextHandles { fn default() -> Self { Self { silent_payments: false, + compressed_transactions: false, chain: ChainHandles::default(), mempool: MempoolHandles::default(), indexes: IndexHandles::default(), @@ -759,6 +841,7 @@ impl Context { pub fn from_handles(handles: ContextHandles) -> Self { let ContextHandles { silent_payments, + compressed_transactions, chain, mempool, indexes, @@ -772,6 +855,7 @@ impl Context { Self { #[cfg(feature = "silent-payments")] silent_payments: silent_payments.then(|| Mutex::new(())), + compressed_transactions, chain, mempool, indexes, @@ -781,6 +865,7 @@ impl Context { debug_log_path, server_bound_at: Mutex::new(None), block_render_budget: Arc::new(BlockRenderBudget::new()), + active_calls: ActiveCalls::new(), } } @@ -859,6 +944,386 @@ impl Context { } } +/// Flat-output prefix row for one transaction in a retained body. +struct FlatRow { + txid: Txid, + first_flat: u32, + output_count: u32, +} + +/// One retained block body: its flat-output prefix rows plus the +/// transactions materialized while building them. +/// +/// The raw bytes are parsed once in [`RpcPrevoutResolver::ensure_body`] +/// and then dropped; only the materialized transactions are retained. +/// They share the body's lifetime and are dropped with it on eviction, +/// so per-input lookups never reparse and retention costs one body-sized +/// copy, not two. +struct RetainedBody { + height: u32, + rows: Vec, + txs: Vec, + index_bytes: usize, + tx_bytes: usize, +} + +/// Conservative accounting for a materialized transaction's owned +/// allocations, including one vector entry per witness item. +fn retained_tx_bytes(txs: &[Tx]) -> usize { + txs.iter() + .map(|tx| { + core::mem::size_of::() + + tx.inputs.len() * core::mem::size_of::() + + tx.outputs.len() * core::mem::size_of::() + + tx.inputs + .iter() + .map(|input| { + input.script_sig.len() + + input.witness.len() * core::mem::size_of::>() + + input.witness.iter().map(Vec::len).sum::() + }) + .sum::() + + tx.outputs + .iter() + .map(|output| output.script_pubkey.len()) + .sum::() + }) + .sum() +} + +/// Production [`PrevoutResolver`] for the BIP337 codec. +/// +/// Consistency: snapshot isolation. Handlers construct and run the +/// resolver inside `ChainHandles::with_stable_chainstate`, so the +/// captured `applied_view()`, UTXO entries, txindex heights, and bodies +/// all come from one transition-excluded chainstate read; a tip that +/// moves mid-call cancels the call instead of mixing branches. It holds one +/// block-render permit for the whole call, retains materialized +/// transactions plus cumulative-output prefix rows per height, and parses +/// each retained body once in a scope that drops the parsed block before +/// the next body is read. Retained materialized transactions are charged +/// against the body and combined budgets and evicted with their body, so +/// the cache lifetime stays bounded. Cumulative loads and bytes bound the +/// total read/parse/materialize/txid work under the exclusion and fail +/// closed; codecs group height-known lookups so each body loads once. +/// Missing, corrupt, and unreadable bodies stay distinct ([`LookupError`]) +/// because the codec degrades them differently. +pub(crate) struct RpcPrevoutResolver<'ctx> { + ctx: &'ctx Context, + view: AppliedView, + /// Held for the whole call; dropping releases the render slot. + _permit: BlockRenderPermit, + cancellation: LatchReader, + bodies: Vec, + retained_tx_bytes: usize, + retained_index_bytes: usize, + /// Cumulative body loads performed (distinct reads; eviction reloads + /// count again, so cycling past the retained slots cannot do unbounded + /// parse work under the exclusion). Never decremented. + loads: usize, + /// Cumulative body bytes read, bounding total parse work. Never + /// decremented. + loaded_bytes: usize, + memo: HashMap>, + state_bytes: usize, +} + +impl<'ctx> RpcPrevoutResolver<'ctx> { + /// Captures the applied view and the call's single render permit. + /// + /// # Errors + /// Returns retryable `Misc` (-1) when both block-render slots are + /// busy; the caller never queues behind a render. + pub(crate) fn new(ctx: &'ctx Context, cancellation: LatchReader) -> Result { + let permit = ctx + .try_acquire_block_render() + .ok_or_else(|| RpcError::Misc("block rendering is busy; retry".to_owned()))?; + Ok(Self { + ctx, + view: ctx.chain.applied_view(), + _permit: permit, + cancellation, + bodies: Vec::new(), + retained_tx_bytes: 0, + retained_index_bytes: 0, + loads: 0, + loaded_bytes: 0, + memo: HashMap::new(), + state_bytes: 0, + }) + } + + /// Whether the captured view still equals the live applied view. + #[must_use] + pub(crate) fn view_matches_current(&self) -> bool { + let current = self.ctx.chain.applied_view(); + match (self.view.tip(), current.tip()) { + (None, None) => true, + (Some(before), Some(now)) => before.height == now.height && before.hash == now.hash, + _ => false, + } + } + + fn is_cancelled(&self) -> bool { + self.cancellation.is_triggered() || !self.view_matches_current() + } + + /// Ensures the body at `height` is retained, evicting oldest-first. + /// Cumulative loads and bytes fail closed before doing body I/O or + /// parsing: without them a request cycling more heights than the + /// retained slots could pay a full body read, parse, materialization, + /// and txid pass per input while holding the stable-chainstate + /// exclusion. Codecs group height-known lookups so each body loads + /// once; this budget backstops the rest. + fn ensure_body(&mut self, height: u32) -> Result<(), LookupError> { + if self.bodies.iter().any(|body| body.height == height) { + return Ok(()); + } + if self.is_cancelled() { + return Err(LookupError::Cancelled); + } + let record = self + .ctx + .chain + .block_by_height_in_view(&self.view, height) + .ok_or(LookupError::Missing)?; + let source = self + .ctx + .chain + .block_body_source + .as_ref() + .ok_or(LookupError::BodyMissing(height))?; + // Fail closed before body I/O so the exclusion never pays for more + // than a bounded number of loads. + if self.loads >= MAX_CUMULATIVE_BODY_LOADS { + return Err(LookupError::LimitExceeded( + crate::compressed_tx::Limit::Loads, + )); + } + let bytes = source + .block_body(height, record.hash) + .map_err(|error| match error { + BlockBodyReadError::Corrupt(_) => LookupError::CorruptBody(height), + BlockBodyReadError::Read(_) => LookupError::BodyReadFailed(height), + })? + .ok_or(LookupError::BodyMissing(height))?; + if bytes.len() > MAX_RETAINED_BODY_BYTES { + return Err(LookupError::LimitExceeded( + crate::compressed_tx::Limit::BodyBytes, + )); + } + if self.loaded_bytes.saturating_add(bytes.len()) > MAX_CUMULATIVE_BODY_BYTES { + return Err(LookupError::LimitExceeded( + crate::compressed_tx::Limit::LoadBytes, + )); + } + self.loads += 1; + self.loaded_bytes = self.loaded_bytes.saturating_add(bytes.len()); + // Pre-charge the transient index space this body will occupy. + let index_bytes = bytes.len() / 60 * 40; + if index_bytes > MAX_RETAINED_INDEX_BYTES { + return Err(LookupError::LimitExceeded( + crate::compressed_tx::Limit::IndexBytes, + )); + } + let (rows, txs) = { + let parsed = + ParsedBlock::parse_exact(&bytes).map_err(|_| LookupError::CorruptBody(height))?; + let mut rows = Vec::with_capacity(parsed.transactions().len()); + let mut txs = Vec::with_capacity(parsed.transactions().len()); + let mut flat = 0_u32; + for parsed_tx in parsed.transactions() { + let tx = parsed_tx.materialize(); + let output_count = u32::try_from(tx.outputs.len()) + .map_err(|_| LookupError::CorruptBody(height))?; + rows.push(FlatRow { + txid: tx.txid(), + first_flat: flat, + output_count, + }); + flat = flat + .checked_add(output_count) + .ok_or(LookupError::CorruptBody(height))?; + txs.push(tx); + } + (rows, txs) + }; + // Charge the owned allocations, not just the wire length: a witness + // item needs a vector entry even when its payload is one byte. + let tx_bytes = retained_tx_bytes(&txs); + while self.bodies.len() >= MAX_RETAINED_BODIES + || self.retained_tx_bytes + tx_bytes > MAX_RETAINED_BODY_BYTES + || self.retained_index_bytes + index_bytes > MAX_RETAINED_INDEX_BYTES + || self.retained_tx_bytes + self.retained_index_bytes + tx_bytes + index_bytes + > MAX_RETAINED_BYTES_TOTAL + { + let Some(evicted) = (!self.bodies.is_empty()).then(|| self.bodies.remove(0)) else { + return Err(LookupError::LimitExceeded( + crate::compressed_tx::Limit::Bodies, + )); + }; + self.retained_tx_bytes -= evicted.tx_bytes; + self.retained_index_bytes -= evicted.index_bytes; + } + self.retained_tx_bytes += tx_bytes; + self.retained_index_bytes += index_bytes; + self.bodies.push(RetainedBody { + height, + rows, + txs, + index_bytes, + tx_bytes, + }); + Ok(()) + } + + /// Borrows the transaction `txid` cached with the retained body at + /// `height` without cloning; the body is parsed once at retain time, + /// so this lookup scans cached transactions without reparsing. + /// Callers clone only the requested output via [`Self::body_txout`]. + fn body_tx_ref(&mut self, height: u32, txid: &Txid) -> Result, LookupError> { + self.ensure_body(height)?; + let Some(body) = self.bodies.iter().find(|body| body.height == height) else { + return Err(LookupError::BodyMissing(height)); + }; + Ok(body + .rows + .iter() + .zip(body.txs.iter()) + .find_map(|(row, tx)| (row.txid == *txid).then_some(tx))) + } + + /// Clones only the requested output `vout` of `txid` at `height`; + /// the retained funding transaction itself is never cloned, so + /// per-input prevout lookups cost one `TxOut`, not one `Tx`. + fn body_txout( + &mut self, + height: u32, + txid: &Txid, + vout: u32, + ) -> Result, LookupError> { + let vout = usize::try_from(vout).map_err(|_| LookupError::Missing)?; + let Some(tx) = self.body_tx_ref(height, txid)? else { + return Ok(None); + }; + Ok(tx.outputs.get(vout).cloned()) + } + + fn resolve_prevout( + &mut self, + outpoint: &OutPoint, + ) -> Result, LookupError> { + if let Some(coin) = self.ctx.chain.utxo.get_entry(outpoint) { + return Ok(Some(ResolvedPrevout { + txout: coin.txout, + confirmed_height: Some(coin.height), + })); + } + let height = match self.ctx.indexes.derived_index.as_ref() { + Some(index) => index + .transaction_height(&outpoint.txid) + .map_err(|_| LookupError::Missing)?, + None => None, + }; + let Some(height) = height else { + return Ok(None); + }; + let Some(txout) = self.body_txout(height, &outpoint.txid, outpoint.vout)? else { + return Ok(None); + }; + Ok(Some(ResolvedPrevout { + txout, + confirmed_height: Some(height), + })) + } +} + +impl PrevoutResolver for RpcPrevoutResolver<'_> { + fn prevout(&mut self, outpoint: &OutPoint) -> Result, LookupError> { + if let Some(cached) = self.memo.get(outpoint) { + return Ok(cached.clone()); + } + let resolved = self.resolve_prevout(outpoint)?; + let charge = 96_usize + + resolved + .as_ref() + .map_or(0, |prevout| prevout.txout.script_pubkey.len()); + let total = self + .state_bytes + .checked_add(charge) + .ok_or(LookupError::LimitExceeded( + crate::compressed_tx::Limit::State, + ))?; + if total > MAX_RESOLVER_STATE_BYTES { + return Err(LookupError::LimitExceeded( + crate::compressed_tx::Limit::State, + )); + } + self.state_bytes = total; + self.memo.insert(*outpoint, resolved.clone()); + Ok(resolved) + } + + fn flat_output_index( + &mut self, + height: u32, + txid: &Txid, + vout: u32, + ) -> Result, LookupError> { + match self.ensure_body(height) { + Err(LookupError::Missing) => return Ok(None), + Err(error) => return Err(error), + Ok(()) => {} + } + let Some(body) = self.bodies.iter().find(|body| body.height == height) else { + return Err(LookupError::BodyMissing(height)); + }; + Ok(body + .rows + .iter() + .find(|row| row.txid == *txid && vout < row.output_count) + .map(|row| row.first_flat + vout)) + } + + fn outpoint_at( + &mut self, + height: u32, + flat_index: u32, + ) -> Result, LookupError> { + match self.ensure_body(height) { + Err(LookupError::Missing) => return Ok(None), + Err(error) => return Err(error), + Ok(()) => {} + } + let located = self.bodies.iter().find_map(|body| { + if body.height != height { + return None; + } + body.rows.iter().find_map(|row| { + (flat_index >= row.first_flat && flat_index < row.first_flat + row.output_count) + .then_some((row.txid, flat_index - row.first_flat)) + }) + }); + let Some((txid, vout)) = located else { + return Ok(None); + }; + let Some(txout) = self.body_txout(height, &txid, vout)? else { + return Ok(None); + }; + Ok(Some(( + OutPoint::new(txid, vout), + ResolvedPrevout { + txout, + confirmed_height: Some(height), + }, + ))) + } + + fn cancelled(&self) -> bool { + self.is_cancelled() + } +} + impl ChainHandles { /// Runs a read with authoritative UTXO and applied-tip transitions excluded. /// @@ -2550,6 +3015,254 @@ mod admission_chain_tests { ); Ok(()) } + + fn burn_raw() -> String { + // Consensus-valid but unspendable: a 1_000-sat OP_RETURN output. + // Inputs never resolve; the burn guard runs before admission. + let burn = Tx { + version: 2, + inputs: vec![TxIn { + previous_output: OutPoint::new(Txid::from(Hash256::from_le_bytes(&[7; 32])), 0), + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + outputs: vec![TxOut { + value: Amount::from_sat(1_000), + script_pubkey: Script::from_bytes(vec![0x6a, 0x01, 0x51]), + }], + lock_time: LockTime::ZERO, + }; + consensus_bytes(&burn).to_lower_hex_string() + } + + #[test] + #[expect(clippy::expect_used)] + fn sendrawtransaction_honors_maxburnamount_before_admission() { + use sonic_rs::json; + + use crate::error::RpcError; + use crate::handlers::tx; + + let rpc_ctx = Arc::new(Context::new()); + let raw = &burn_raw(); + // Default cap 0: the burn guard fires before admission is attempted, + // so even an unfunded burn is refused at the guard, not the pool. + let refused = tx::sendrawtransaction(&rpc_ctx, &json!([raw])) + .expect_err("burn above the default cap must be refused"); + assert!( + refused.to_string().contains("maxburnamount"), + "unexpected refusal: {refused}" + ); + // A cap covering the burn output passes the guard; admission then + // refuses the unknown prevout instead. + let refused = tx::sendrawtransaction(&rpc_ctx, &json!([raw, null, "0.00001"])) + .expect_err("unknown prevout must still be refused"); + assert_eq!(refused.code(), RpcError::CORE_VERIFY_ERROR); + // An explicit cap below the burn still refuses at the guard. + let refused = tx::sendrawtransaction(&rpc_ctx, &json!([raw, null, "0.00000999"])) + .expect_err("burn above the explicit cap must be refused"); + assert!( + refused.to_string().contains("maxburnamount"), + "unexpected refusal: {refused}" + ); + // The named form carries the same cap. + let refused = tx::sendrawtransaction( + &rpc_ctx, + &json!({"hexstring": raw, "maxburnamount": "0.00001"}), + ) + .expect_err("unknown prevout must still be refused"); + assert_eq!(refused.code(), RpcError::CORE_VERIFY_ERROR); + } + + #[derive(Debug, Default)] + struct RecordingPublisher { + hashtx: std::sync::Mutex>, + } + + impl crate::zmq::ZmqPublisher for RecordingPublisher { + fn publish_hashblock(&self, _hash: Hash256) {} + + fn publish_hashtx(&self, txid: Txid) { + if let Ok(mut seen) = self.hashtx.lock() { + seen.push(txid); + } + } + + fn publish_rawblock(&self, _bytes: &[u8]) {} + + fn publish_rawtx(&self, _bytes: &[u8]) {} + } + + #[test] + #[expect(clippy::expect_used)] + fn sendrawtransaction_publishes_hashtx_once_per_admission() -> anyhow::Result<()> { + use sonic_rs::json; + + use crate::handlers::tx; + + let outpoint = OutPoint::new(Txid::from(Hash256::from_le_bytes(&[31; 32])), 0); + let spend = spending(outpoint); + let raw = &consensus_bytes(&spend).to_lower_hex_string(); + let mut changes = BlockChanges::default(); + changes.add(UtxoAdd::new( + outpoint, + TxOut { + value: Amount::from_sat(10_000), + script_pubkey: Script::from_bytes(spendable_script()), + }, + false, + 0, + )); + + let recording = Arc::new(RecordingPublisher::default()); + let publisher: Arc = recording.clone(); + let mut fresh_ctx = Context::new_with_mempool_observer(Arc::new( + crate::zmq::MempoolSequenceObserver::new(publisher.clone()), + )); + fresh_ctx.zmq_publisher = publisher; + let rpc_ctx = Arc::new(fresh_ctx); + let published = || { + recording + .hashtx + .lock() + .map(|seen| seen.clone()) + .unwrap_or_default() + }; + bitcoin_rs_utxo::contract::commit_block_changes( + &rpc_ctx.chain.utxo.fixture_set(), + &changes, + &Hash256::default(), + )?; + + // Accepted: one `hashtx` for the admitted txid. + tx::sendrawtransaction(&rpc_ctx, &json!([raw]))?; + assert_eq!( + published(), + vec![spend.txid()], + "admission must publish exactly one hashtx" + ); + + // Duplicate: already known, so the second call publishes nothing. + tx::sendrawtransaction(&rpc_ctx, &json!([raw]))?; + assert_eq!( + published().len(), + 1, + "a duplicate admission must not republish" + ); + + // Rejected: an unknown prevout publishes nothing. + let orphan = spending(OutPoint::new( + Txid::from(Hash256::from_le_bytes(&[32; 32])), + 0, + )); + let orphan_raw = consensus_bytes(&orphan).to_lower_hex_string(); + tx::sendrawtransaction(&rpc_ctx, &json!([orphan_raw])) + .expect_err("an unknown prevout must be refused"); + assert_eq!( + published().len(), + 1, + "a rejected submission must not publish" + ); + Ok(()) + } + + #[test] + fn submitpackage_publishes_hashtx_only_for_newly_admitted() -> anyhow::Result<()> { + use sonic_rs::json; + + use crate::handlers::tx::submit_package::submitpackage; + + let coin = OutPoint::new(Txid::from(Hash256::from_le_bytes(&[41; 32])), 0); + let parent = spending(coin); + let child = Tx { + version: 2, + inputs: vec![TxIn { + previous_output: OutPoint::new(parent.txid(), 0), + script_sig: Script::new(), + sequence: Sequence::MAX, + witness: Witness::from_stack(vec![vec![0x51]]), + }], + outputs: vec![TxOut { + value: Amount::from_sat(7_000), + script_pubkey: Script::from_bytes(spendable_script()), + }], + lock_time: LockTime::ZERO, + }; + let raws = [ + consensus_bytes(&parent).to_lower_hex_string(), + consensus_bytes(&child).to_lower_hex_string(), + ]; + let mut changes = BlockChanges::default(); + changes.add(UtxoAdd::new( + coin, + TxOut { + value: Amount::from_sat(10_000), + script_pubkey: Script::from_bytes(spendable_script()), + }, + false, + 0, + )); + + let recording = Arc::new(RecordingPublisher::default()); + let publisher: Arc = recording.clone(); + let mut fresh_ctx = Context::new_with_mempool_observer(Arc::new( + crate::zmq::MempoolSequenceObserver::new(publisher.clone()), + )); + fresh_ctx.zmq_publisher = publisher; + let rpc_ctx = Arc::new(fresh_ctx); + let published = || { + recording + .hashtx + .lock() + .map(|seen| seen.clone()) + .unwrap_or_default() + }; + bitcoin_rs_utxo::contract::commit_block_changes( + &rpc_ctx.chain.utxo.fixture_set(), + &changes, + &Hash256::default(), + )?; + + // Both members are newly admitted: one `hashtx` each, in order. + submitpackage(&rpc_ctx, &json!([[raws[0].clone(), raws[1].clone()]]))?; + assert_eq!( + published(), + vec![parent.txid(), child.txid()], + "each newly admitted member must publish hashtx" + ); + + // Re-submission finds residents: rows carry no aggregate rate, so + // nothing publishes again. + submitpackage(&rpc_ctx, &json!([[raws[0].clone(), raws[1].clone()]]))?; + assert_eq!(published().len(), 2, "resident members must not republish"); + Ok(()) + } + + #[test] + #[expect(clippy::expect_used)] + fn sendrawtransaction_rejects_unknown_extra_params() { + use sonic_rs::json; + + use crate::handlers::tx; + + let rpc_ctx = Arc::new(Context::new()); + let raw = &burn_raw(); + // A fourth positional parameter is rejected before decoding. + let refused = tx::sendrawtransaction(&rpc_ctx, &json!([raw, null, null, "extra"])) + .expect_err("a fourth parameter must be refused"); + assert!( + refused.to_string().contains("too many parameters"), + "unexpected refusal: {refused}" + ); + // An unknown named parameter is rejected before decoding. + let refused = tx::sendrawtransaction(&rpc_ctx, &json!({"hexstring": raw, "bogus": 1})) + .expect_err("an unknown named parameter must be refused"); + assert!( + refused.to_string().contains("Unknown named parameter"), + "unexpected refusal: {refused}" + ); + } } #[cfg(test)] diff --git a/crates/rpc/src/convert.rs b/crates/rpc/src/convert.rs index 4e5285bd5..fcec01fcb 100644 --- a/crates/rpc/src/convert.rs +++ b/crates/rpc/src/convert.rs @@ -49,15 +49,12 @@ pub(crate) const fn chain_name(network: Network) -> &'static str { } } -/// Saturating `u64 -> i64` for Core wire counters typed as `i64`. +/// Saturating integer `-> i64` for Core wire counters typed as `i64`. #[must_use] -pub(crate) fn i64_saturated(value: u64) -> i64 { - i64::try_from(value).unwrap_or(i64::MAX) -} - -/// Saturating `usize -> i64` for Core wire counters typed as `i64`. -#[must_use] -pub(crate) fn i64_saturated_len(value: usize) -> i64 { +pub(crate) fn i64_saturated(value: T) -> i64 +where + i64: TryFrom, +{ i64::try_from(value).unwrap_or(i64::MAX) } @@ -300,12 +297,20 @@ 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 { - sonic_rs::from_value(&tx_render::script_pub_key_json(script, network)).map_err(RpcError::from) +/// constructed directly from the shape classification and renderers. +pub(crate) fn script_pub_key_typed(script: &[u8], network: Network) -> corepc_types::ScriptPubKey { + let shape = classify(script); + corepc_types::ScriptPubKey { + asm: tx_render::script_asm(script, false), + descriptor: Some(tx_render::script_descriptor(script, network)), + hex: script.to_lower_hex_string(), + required_signatures: None, + type_: core_type_name(shape).to_owned(), + address: (shape != ScriptShape::Pubkey) + .then(|| script_address(script, network)) + .flatten(), + addresses: None, + } } /// The coinbase transaction object carried by verbose block responses. diff --git a/crates/rpc/src/error.rs b/crates/rpc/src/error.rs index 47e114542..fe098dd7a 100644 --- a/crates/rpc/src/error.rs +++ b/crates/rpc/src/error.rs @@ -7,8 +7,19 @@ pub enum RpcError { #[error("parse error: {0}")] Parse(String), /// Request object is not a valid JSON-RPC call. - #[error("invalid request: {0}")] + /// + /// The payload is rendered verbatim: Core's `JSONRPCError` message text + /// has no prefix (`src/rpc/request.cpp:206-260`), and the pinned client + /// comparison checks the exact string. + #[error("{0}")] InvalidRequest(&'static str), + /// A top-level JSON value that is neither an object nor an array. + /// + /// Bitcoin Core answers `RPC_PARSE_ERROR` (-32700) with its own message + /// for this case (`src/httprpc.cpp`: "Top-level object parse error"). + /// The payload is rendered verbatim. + #[error("{0}")] + TopLevelParse(&'static str), /// Method name is not supported. #[error("method not found: {0}")] MethodNotFound(String), @@ -69,7 +80,7 @@ pub enum RpcError { impl RpcError { const PARSE_ERROR: i64 = -32_700; - const INVALID_REQUEST: i64 = -32_600; + pub const INVALID_REQUEST: i64 = -32_600; pub const METHOD_NOT_FOUND: i64 = -32_601; pub const INVALID_PARAMS: i64 = -32_602; pub const INTERNAL_ERROR: i64 = -32_603; @@ -86,7 +97,7 @@ impl RpcError { #[must_use] pub const fn code(&self) -> i64 { match self { - Self::Parse(_) => Self::PARSE_ERROR, + Self::Parse(_) | Self::TopLevelParse(_) => Self::PARSE_ERROR, Self::Misc(_) => -1, Self::InvalidRequest(_) => Self::INVALID_REQUEST, Self::MethodNotFound(_) => Self::METHOD_NOT_FOUND, diff --git a/crates/rpc/src/esplora/public.rs b/crates/rpc/src/esplora/public.rs index 623fcbbe0..8b54455dc 100644 --- a/crates/rpc/src/esplora/public.rs +++ b/crates/rpc/src/esplora/public.rs @@ -161,14 +161,16 @@ pub(super) fn post(handler: &Handler, path: &str, body: &[u8]) -> Response { unix_time_secs(), &ctx.chain.admission_chain(), ) { + // `hashtx` for a newly admitted transaction is published once + // by the gateway observer; duplicates commit nothing and + // notify nothing. Ok(_) => text_response("text/plain", txid.to_string().into_bytes()), Err(error) => admission_error(error), } } // `/txs/package` is in this namespace so it cannot fall through to - // JSON-RPC. API.md makes package relay conditional on a - // package-admission backend; 404 is preferable to sequential - // sendrawtransaction calls pretending to be atomic. + // JSON-RPC. This endpoint remains unsupported until its Esplora wire + // contract is implemented over the gateway's package operation. _ => not_found(), } } diff --git a/crates/rpc/src/handlers.rs b/crates/rpc/src/handlers.rs index bb8bd58ec..cfe2cd4bc 100644 --- a/crates/rpc/src/handlers.rs +++ b/crates/rpc/src/handlers.rs @@ -127,6 +127,10 @@ impl Handler { }) }) .transpose()?; + // Registration sits after registry resolution and blocking-quota + // acquisition, so unknown or refused methods never appear as + // active; the guard removes this call's entry on every exit path. + let _active = self.ctx.active_calls.begin(method); let cancellation = bitcoin_rs_chain::LatchReader::new(Arc::clone(&self.stop)); match handler { crate::registry::Dispatch::Immediate(handler) => handler(&self.ctx, params), diff --git a/crates/rpc/src/handlers/chain.rs b/crates/rpc/src/handlers/chain.rs index c56e823c8..7e9d3b2cd 100644 --- a/crates/rpc/src/handlers/chain.rs +++ b/crates/rpc/src/handlers/chain.rs @@ -15,12 +15,14 @@ use corepc_types::v31::{self, ChainTips, ChainTipsStatus}; use sonic_rs::{JsonContainerTrait as _, JsonValueMutTrait as _, JsonValueTrait, Value, json}; use super::util::{ScanScript, parse_derivation_range, scan_descriptor}; +#[cfg(test)] +use super::util::scan_descriptor_range; use bitcoin::hex::DisplayHex as _; use crate::context::{AppliedView, ChainControlError, Context, TxQueryError, TxUnavailableCause}; use crate::convert::{ - self, compact_target_hex, i32_saturated, i64_saturated, i64_saturated_len, sat_to_btc, - typed_to_sonic, typed_to_sonic_omitting_nulls, + self, compact_target_hex, i32_saturated, i64_saturated, sat_to_btc, typed_to_sonic, + typed_to_sonic_omitting_nulls, }; use crate::error::RpcError; use crate::handlers::{ @@ -1202,7 +1204,7 @@ pub(crate) fn gettxoutsetinfo(ctx: &Arc, params: &Value) -> Result 0).then(|| header.prev_blockhash.to_string()), next_block_hash: next_block_hash.map(|hash| hash.to_string()), }; @@ -6353,6 +6355,164 @@ mod scantxoutset_tests { .unwrap_or_else(|err| panic!("scantxoutset abort failed: {err}")); assert_eq!(result.as_bool(), Some(false)); } + + #[test] + fn ranged_descriptor_matches_fixed_descriptors() { + let ctx = Arc::new(Context::new()); + let xpub = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; + let ranged_desc = format!("wpkh({xpub}/0/*)"); + let fixed_0 = format!("wpkh({xpub}/0/0)"); + let fixed_1 = format!("wpkh({xpub}/0/1)"); + let fixed_2 = format!("wpkh({xpub}/0/2)"); + + let scripts = + scan_descriptor_range(&ranged_desc, Network::Mainnet, Some((0, 2)), || Ok(false)) + .unwrap_or_else(|err| panic!("derive ranged scripts: {err}")); + assert_eq!(scripts.len(), 3); + + // Also test scan_descriptor on a fixed form + let single = scan_descriptor_range(&fixed_0, Network::Mainnet, None, || Ok(false)) + .unwrap_or_else(|err| panic!("scan_descriptor fixed form: {err}")) + .pop() + .unwrap_or_else(|| panic!("scan_descriptor fixed form produced no script")); + assert_eq!(single.script_pubkey, scripts[0].script_pubkey); + assert_eq!(single.desc, scripts[0].desc); + + for (i, s) in scripts.iter().enumerate() { + let idx_u64 = u64::try_from(i).unwrap_or(0); + let idx_u32 = u32::try_from(i).unwrap_or(0); + let outpoint = OutPoint::new(Txid::from(test_txid(50 + idx_u64)), idx_u32); + let txout = TxOut { + value: Amount::from_sat(10_000 * (idx_u64 + 1)), + script_pubkey: s.script_pubkey.clone().into(), + }; + commit_test_utxo(&ctx, outpoint, txout, false, 0); + } + + let ranged_result = scantxoutset( + &ctx, + &json!(["start", [{"desc": ranged_desc, "range": [0, 2]}]]), + ) + .unwrap_or_else(|err| panic!("ranged scan failed: {err}")); + + let fixed_result = scantxoutset(&ctx, &json!(["start", [fixed_0, fixed_1, fixed_2]])) + .unwrap_or_else(|err| panic!("fixed scan failed: {err}")); + + assert_eq!(ranged_result["txouts"], fixed_result["txouts"]); + assert_eq!(ranged_result["total_amount"], fixed_result["total_amount"]); + assert_eq!(ranged_result["unspents"], fixed_result["unspents"]); + + let unspents = ranged_result["unspents"] + .as_array() + .unwrap_or_else(|| panic!("unspents missing: {ranged_result:?}")); + assert_eq!(unspents.len(), 3); + for entry in unspents { + assert!(entry.get("txid").is_some()); + assert!(entry.get("vout").is_some()); + assert!(entry.get("amount").is_some()); + assert!(entry.get("desc").is_some()); + } + } + + #[test] + fn multipath_expansion_uses_index_major_branch_inner_order() { + let xpub = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; + let desc = format!("wpkh({xpub}/<0;1>/*)"); + let scripts = scan_descriptor_range(&desc, Network::Mainnet, Some((0, 2)), || Ok(false)) + .unwrap_or_else(|err| panic!("scan_descriptor_range failed: {err}")); + assert_eq!(scripts.len(), 6); + // Multipath expansion must be index-major / branch-inner: + // index 0: /0/0, /1/0 + // index 1: /0/1, /1/1 + // index 2: /0/2, /1/2 + let descs: Vec<&str> = scripts.iter().map(|s| s.desc.as_str()).collect(); + assert!( + descs[0].contains("/0/0]"), + "expected /0/0, got {}", + descs[0] + ); + assert!( + descs[1].contains("/1/0]"), + "expected /1/0, got {}", + descs[1] + ); + assert!( + descs[2].contains("/0/1]"), + "expected /0/1, got {}", + descs[2] + ); + assert!( + descs[3].contains("/1/1]"), + "expected /1/1, got {}", + descs[3] + ); + assert!( + descs[4].contains("/0/2]"), + "expected /0/2, got {}", + descs[4] + ); + assert!( + descs[5].contains("/1/2]"), + "expected /1/2, got {}", + descs[5] + ); + } + + #[test] + fn ranged_descriptor_budget_bounds_and_omitted_multipath_default() { + let ctx = Arc::new(Context::new()); + let xpub = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; + + // [0, 1023] is 1024 scripts: within budget, succeeds + let ok = scantxoutset( + &ctx, + &json!(["start", [{"desc": format!("wpkh({xpub}/0/*)"), "range": [0, 1023]}]]), + ); + assert!(ok.is_ok(), "range [0, 1023] must succeed: {ok:?}"); + + // [0, 1024] is 1025 scripts: fails before derivation + let err_1024 = scantxoutset( + &ctx, + &json!(["start", [{"desc": format!("wpkh({xpub}/0/*)"), "range": [0, 1024]}]]), + ) + .err() + .unwrap_or_else(|| panic!("range [0, 1024] must fail")); + assert_eq!(err_1024.code(), -8); + assert!( + err_1024 + .to_string() + .contains("At most 1024 scan descriptors are supported") + ); + + // Omitted range on multipath descriptor defaults to [0, 1000] (1001 indices * 2 paths = 2002 scripts): fails + let err_multipath = + scantxoutset(&ctx, &json!(["start", [format!("wpkh({xpub}/<0;1>/*)")]])) + .err() + .unwrap_or_else(|| panic!("omitted multipath default must fail on 2002 scripts")); + assert_eq!(err_multipath.code(), -8); + assert!( + err_multipath + .to_string() + .contains("At most 1024 scan descriptors are supported") + ); + } + + #[test] + fn cancellation_during_ranged_scan_releases_reservation() { + let ctx = Arc::new(Context::new()); + let xpub = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; + let stop = Arc::new(core::sync::atomic::AtomicBool::new(true)); + let cancel_reader = bitcoin_rs_chain::LatchReader::new(stop); + + let result = super::scantxoutset( + &ctx, + &json!(["start", [{"desc": format!("wpkh({xpub}/0/*)"), "range": [0, 10]}]]), + &cancel_reader, + ) + .unwrap_or_else(|err| panic!("cancelled scan returned error: {err}")); + assert_eq!(result["success"].as_bool(), Some(false)); + assert_eq!(ctx.chain.utxo.scan_progress(), None); + } } #[cfg(test)] diff --git a/crates/rpc/src/handlers/tx.rs b/crates/rpc/src/handlers/tx.rs index 26a0a998c..1416fbd3c 100644 --- a/crates/rpc/src/handlers/tx.rs +++ b/crates/rpc/src/handlers/tx.rs @@ -23,6 +23,8 @@ use crate::handlers::{optional_bool, params_array, parse_txid, required_str, req use bitcoin_rs_index::block_log::BlockRecord; use corepc_types::v31; +pub(crate) mod submit_package; + /// Decodes a lowercase or uppercase hexadecimal string into bytes. /// /// A non-hex string is a correctly typed but unacceptable value, so it @@ -231,7 +233,7 @@ fn txout_typed( script_pubkey: convert::script_pub_key_typed( &output.script_pubkey, ctx.chain.chain_network, - )?, + ), coinbase, }) } @@ -360,18 +362,40 @@ fn proof_from_block_log( ) -> Result { let len = ctx.chain.blocks.read().len(); let mut saw_pruned_block = false; + // Scan-cost instrumentation: any future bound on this fallback must be + // adopted from these counters on representative successful scans, not + // from a guess (ENG-74). The scan is forward from genesis, so a + // late-window txid costs nearly the whole log and its bodies. + let mut records_scanned: u64 = 0; + let mut body_bytes_scanned: u64 = 0; for index in 0..len { let Some(record) = ctx.chain.blocks.read().get(index).cloned() else { break; }; + records_scanned += 1; let Some(bytes) = ctx.chain.block_body_bytes(&record) else { saw_pruned_block = true; continue; }; + body_bytes_scanned = + body_bytes_scanned.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); if let Some(proof) = proof_from_body(&bytes, wanted) { + tracing::debug!( + records_scanned, + body_bytes_scanned, + found = true, + "gettxoutproof fallback scan" + ); return Ok(proof); } } + tracing::debug!( + records_scanned, + body_bytes_scanned, + found = false, + saw_pruned_block, + "gettxoutproof fallback scan" + ); if saw_pruned_block { Err(RpcError::NotFound("block data pruned")) @@ -452,15 +476,44 @@ pub(crate) fn verifytxoutproof(_ctx: &Arc, params: &Value) -> Result, params: &Value) -> Result { + let bound = super::bind_named_params(params, &["hexstring", "maxfeerate", "maxburnamount"])?; + let params = bound.as_ref(); + super::ensure_at_most_params(params, 3)?; let raw = required_str(params, 0, "raw transaction is required")?; let max_feerate = send_max_feerate(params, 1)?; + let max_burn = params_array(params)? + .get(2) + .filter(|value| !value.is_null()) + .map(parse_btc_amount) + .transpose()? + .unwrap_or(0); + if max_burn > Amount::MAX_MONEY.to_sat() { + return Err(RpcError::InvalidParameter( + "maxburnamount is out of range".to_owned(), + )); + } let tx = decode_tx( raw, "TX decode failed. Make sure the tx has at least one input.".to_owned(), )?; + for output in &tx.outputs { + let script = output.script_pubkey.as_bytes(); + // Core's burn guard is per output, not a sum. HasValidOps is + // already owned by the script crate, including malformed pushes. + if (script.len() > bitcoin_rs_script::MAX_SCRIPT_SIZE + || bitcoin_rs_script::is_op_return(script) + || !bitcoin_rs_script::has_valid_ops(script)) + && output.value.to_sat() > max_burn + { + return Err(RpcError::TxVerifyError( + "Unspendable output exceeds maximum configured by user (maxburnamount)".to_owned(), + )); + } + } let txid = tx.txid(); - ctx.mempool + let _mutation = ctx + .mempool .gateway .submit_local_transaction( Arc::new(tx), @@ -470,6 +523,9 @@ pub(crate) fn sendrawtransaction(ctx: &Arc, params: &Value) -> Result, params: &Value) -> Result Ok(value) } +/// BIP337 `compressrawtransaction`: encodes one raw transaction into +/// its compressed form, degrading unresolvable outpoints to raw form +/// with one warning each. Disabled (`-32603`) unless the node started +/// with `--compressed-transactions`. +pub(crate) fn compressrawtransaction( + ctx: &Arc, + params: &Value, + cancellation: &bitcoin_rs_chain::LatchReader, +) -> Result { + if !ctx.compressed_transactions { + return Err(RpcError::MethodDisabled( + "compressrawtransaction is disabled; enable --compressed-transactions", + )); + } + let bound = super::bind_named_params(params, &["hexstring", "compress_outpoints"])?; + let params = bound.as_ref(); + let raw = required_str(params, 0, "raw transaction is required")?; + if raw.len() > crate::compressed_tx::MAX_COMPRESSED_TX_BYTES * 2 { + return Err(RpcError::InvalidParameter( + "hex string exceeds the compressed transaction size limit".to_owned(), + )); + } + let compress_outpoints = optional_bool(params, 1, true)?; + let tx = decode_tx(raw, "TX decode failed".to_owned())?; + // Snapshot isolation: the resolver captures its applied view and + // resolves UTXO, txindex, and body facts only while this stable + // chainstate read excludes transitions, so one call cannot pair a + // new-branch UTXO entry with the captured height and body. + ctx.chain.with_stable_chainstate(|| { + let mut resolver = crate::context::RpcPrevoutResolver::new(ctx, cancellation.clone())?; + let (compressed, warnings) = crate::compressed_tx::compress_transaction( + &tx, + &mut resolver, + crate::compressed_tx::CompressOptions { compress_outpoints }, + ) + .map_err(RpcError::from)?; + if cancellation.is_triggered() || !resolver.view_matches_current() { + return Err(RpcError::Misc( + "compressed transaction request cancelled or the applied tip moved; retry" + .to_owned(), + )); + } + Ok(json!({ + "result": compressed.to_lower_hex_string(), + "warnings": warnings, + })) + }) +} + +/// BIP337 `decompressrawtransaction`: restores one compressed +/// transaction to its raw hex form, refusing when the context its +/// compressed outpoints or signatures commit to is unavailable. +/// Disabled (`-32603`) unless the node started with +/// `--compressed-transactions`. +pub(crate) fn decompressrawtransaction( + ctx: &Arc, + params: &Value, + cancellation: &bitcoin_rs_chain::LatchReader, +) -> Result { + if !ctx.compressed_transactions { + return Err(RpcError::MethodDisabled( + "decompressrawtransaction is disabled; enable --compressed-transactions", + )); + } + let bound = super::bind_named_params(params, &["hexstring"])?; + let params = bound.as_ref(); + let raw = required_str(params, 0, "compressed transaction is required")?; + if raw.len() > crate::compressed_tx::MAX_COMPRESSED_TX_BYTES * 2 { + return Err(RpcError::InvalidParameter( + "hex string exceeds the compressed transaction size limit".to_owned(), + )); + } + let bytes = + hex_decode(raw).map_err(|_| RpcError::Deserialization("TX decode failed".to_owned()))?; + // Snapshot isolation, as in `compressrawtransaction`: every + // resolution read runs under the same stable chainstate read that + // captured the applied view. Public-key recovery runs after the lock + // is released: it only reads the resolved plan, so a large request + // cannot block chain transitions while grinding recovery ids. A tip + // move during recovery is caught by the second view check below. + let (plan, resolver) = ctx.chain.with_stable_chainstate(|| { + let mut resolver = crate::context::RpcPrevoutResolver::new(ctx, cancellation.clone())?; + let plan = crate::compressed_tx::decompress_prepare(&bytes, &mut resolver) + .map_err(RpcError::from)?; + Ok::<_, RpcError>((plan, resolver)) + })?; + let tx = crate::compressed_tx::decompress_finalize(plan, &resolver).map_err(RpcError::from)?; + ctx.chain.with_stable_chainstate(|| { + if cancellation.is_triggered() || !resolver.view_matches_current() { + return Err(RpcError::Misc( + "compressed transaction request cancelled or the applied tip moved; retry" + .to_owned(), + )); + } + Ok(json!(consensus_bytes(&tx).to_lower_hex_string())) + }) +} + const CREATE_TRANSACTION_ARGUMENTS: &[&str] = &["inputs", "outputs", "locktime", "replaceable"]; pub(crate) fn createrawtransaction(ctx: &Arc, params: &Value) -> Result { @@ -682,26 +836,155 @@ fn decode_tx(raw: &str, message: String) -> Result { native_deserialize(&bytes).map_err(|_| RpcError::Deserialization(message)) } -/// f64 nearest to 2^64 — the value `u64::MAX` rounds to as a float, and the -/// first `f64` that no longer fits `u64` satoshis. A literal because -/// `as_conversions` bans the cast that would compute it. -const U64_MAX_F64: f64 = 18_446_744_073_709_551_616.0; +/// Converts a BTC-denominated JSON value (number or string) to satoshis using exact +/// decimal parsing, expanding e/E notation without floating point arithmetic and +/// rejecting signs other than optional positive, non-digits, more than 8 fractional +/// decimals, fractional satoshis, and overflow. +#[expect( + clippy::too_many_lines, + reason = "one amount parser keeps sign, exponent, mantissa, and scale checks in a single auditable sequence" +)] +fn sats_from_btc(value: &Value, message: &'static str) -> Result { + let text = if let Some(text) = value.as_str() { + text.to_owned() + } else if let Some(raw) = value.as_raw_number() { + raw.as_str().to_owned() + } else if value.is_number() { + value.to_string() + } else { + return Err(RpcError::InvalidType( + "amount must be a number or string".to_owned(), + )); + }; -/// Converts a BTC-denominated float to satoshis, rejecting non-finite, -/// negative, and overflow values with `message`. -/// -/// The final step is a scoped `as`: std offers no `TryFrom for u64`, and -/// the range check above already bounds `raw` to `[0, 2^64)`, so the cast -/// only truncates fractional satoshi dust, matching the historical behavior. -fn sats_from_btc(btc: f64, message: &'static str) -> Result { - let raw = btc * 100_000_000.0; - if !raw.is_finite() || !(0.0..U64_MAX_F64).contains(&raw) { + let text = text.as_str(); + if text.is_empty() { + return Err(RpcError::InvalidParams(message)); + } + + // Optional positive sign; reject any other sign including negative. + let text = if let Some(stripped) = text.strip_prefix('+') { + stripped + } else if text.starts_with('-') { + return Err(RpcError::InvalidParams(message)); + } else { + text + }; + + if text.is_empty() { + return Err(RpcError::InvalidParams(message)); + } + + // Parse scientific notation: mantissa and optional exponent. + let (mantissa, exp) = if let Some(pos) = text.find(['e', 'E']) { + let (m, e) = text.split_at(pos); + let e_str = &e[1..]; + if m.is_empty() || e_str.is_empty() { + return Err(RpcError::InvalidParams(message)); + } + let (e_sign, e_digits) = if let Some(stripped) = e_str.strip_prefix('+') { + (1_i32, stripped) + } else if let Some(stripped) = e_str.strip_prefix('-') { + (-1_i32, stripped) + } else { + (1_i32, e_str) + }; + if e_digits.is_empty() || !e_digits.chars().all(|c| c.is_ascii_digit()) { + return Err(RpcError::InvalidParams(message)); + } + let exp_mag: i32 = e_digits + .parse() + .map_err(|_| RpcError::InvalidParams(message))?; + ( + m, + e_sign + .checked_mul(exp_mag) + .ok_or(RpcError::InvalidParams(message))?, + ) + } else { + (text, 0_i32) + }; + + // Split mantissa on decimal point. Like Core's ParseFixedPoint, a + // decimal point requires at least one digit on each side ("1." and + // ".1" are malformed), and a second point is garbage. + let (int_part, frac_part) = if let Some(pos) = mantissa.find('.') { + let (i, f) = mantissa.split_at(pos); + let f = &f[1..]; + if i.is_empty() || f.is_empty() || f.contains('.') { + return Err(RpcError::InvalidParams(message)); + } + (i, f) + } else { + (mantissa, "") + }; + + // Both parts must contain only ASCII digits, and the integer part + // must carry at least one digit. + if int_part.is_empty() + || !int_part.chars().all(|c| c.is_ascii_digit()) + || !frac_part.chars().all(|c| c.is_ascii_digit()) + { return Err(RpcError::InvalidParams(message)); } - #[expect(clippy::as_conversions)] // see fn doc: no TryFrom for u64 in std - #[expect(clippy::cast_possible_truncation)] // fractional dust, per fn doc - #[expect(clippy::cast_sign_loss)] // raw >= 0.0 checked above - Ok(raw as u64) + + let mut digits = Vec::with_capacity(int_part.len() + frac_part.len()); + digits.extend_from_slice(int_part.as_bytes()); + digits.extend_from_slice(frac_part.as_bytes()); + + if digits.iter().all(|&d| d == b'0') { + return Ok(0); + } + + // Normalize: trailing zeros carry no value, so the fractional-decimal + // count is taken after trimming them ("100e-10" and "1.000000000" are + // whole satoshis, not nine-decimal amounts). + while digits.last() == Some(&b'0') { + digits.pop(); + } + + let initial_point = + i32::try_from(int_part.len()).map_err(|_| RpcError::InvalidParams(message))?; + let point = initial_point + .checked_add(exp) + .ok_or(RpcError::InvalidParams(message))?; + + let digits_len = i32::try_from(digits.len()).map_err(|_| RpcError::InvalidParams(message))?; + let frac_digits = digits_len + .checked_sub(point) + .ok_or(RpcError::InvalidParams(message))?; + if frac_digits > 8 { + return Err(RpcError::InvalidParams(message)); + } + + let sat_point = point + .checked_add(8) + .ok_or(RpcError::InvalidParams(message))?; + if sat_point < digits_len { + return Err(RpcError::InvalidParams(message)); + } + + let trailing_zeros = sat_point + .checked_sub(digits_len) + .ok_or(RpcError::InvalidParams(message))?; + if trailing_zeros > 20 { + return Err(RpcError::InvalidParams(message)); + } + + let mut sats: u64 = 0; + for &d in &digits { + sats = sats + .checked_mul(10) + .and_then(|v| v.checked_add(u64::from(d - b'0'))) + .ok_or(RpcError::InvalidParams(message))?; + } + for _ in 0..trailing_zeros { + sats = sats + .checked_mul(10) + .ok_or(RpcError::InvalidParams(message))?; + } + + Ok(sats) } /// `sendrawtransaction`'s fee argument: Core's `maxfeerate` amount, or the @@ -736,23 +1019,28 @@ fn optional_max_feerate(params: &Value, index: usize) -> Result, Rpc if value.is_null() { return Ok(Some(DEFAULT_MAX_RAW_TX_FEE_RATE_SAT_PER_KVB)); } - let btc_per_kvb = if let Some(number) = value.as_f64() { - number - } else if let Some(text) = value.as_str() { - text.parse::() - .map_err(|_| RpcError::InvalidType("maxfeerate must be a number".to_owned()))? - } else { + if !value.is_number() && !value.is_str() { return Err(RpcError::InvalidType( "maxfeerate must be a number".to_owned(), )); - }; - if !btc_per_kvb.is_finite() || btc_per_kvb < 0.0 { - return Err(RpcError::InvalidParams("maxfeerate must be non-negative")); } - if btc_per_kvb == 0.0 { + if let Some(text) = value.as_str() { + if text.starts_with('-') { + return Err(RpcError::InvalidParams("maxfeerate must be non-negative")); + } + } else if let Some(raw) = value.as_raw_number() { + if raw.as_str().starts_with('-') { + return Err(RpcError::InvalidParams("maxfeerate must be non-negative")); + } + } else if let Some(f) = value.as_f64() { + if f < 0.0 { + return Err(RpcError::InvalidParams("maxfeerate must be non-negative")); + } + } + let sats = sats_from_btc(value, "maxfeerate is out of range")?; + if sats == 0 { return Ok(None); } - let sats = sats_from_btc(btc_per_kvb, "maxfeerate is out of range")?; // Core's `ParseFeeRate` refuses rates at or above 1 BTC/kvB, so the // ceiling is enforced on the integer domain to keep the parameter // contract identical. @@ -765,18 +1053,7 @@ fn optional_max_feerate(params: &Value, index: usize) -> Result, Rpc } fn parse_btc_amount(value: &Value) -> Result { - if let Some(number) = value.as_f64() { - return sats_from_btc(number, "Invalid amount"); - } - if let Some(text) = value.as_str() { - let number: f64 = text - .parse() - .map_err(|_| RpcError::InvalidParams("Invalid amount"))?; - return sats_from_btc(number, "Invalid amount"); - } - Err(RpcError::InvalidType( - "amount must be a number or string".to_owned(), - )) + sats_from_btc(value, "Invalid amount") } // --------------------------------------------------------------------------- @@ -2896,4 +3173,163 @@ mod finalizepsbt_tests { assert!(result.get("psbt").is_none_or(Value::is_null)); assert!(result.get("hex").and_then(Value::as_str).is_some()); } + + #[test] + fn exact_amount_parsing_honors_precision_invariants() { + use super::sats_from_btc; + + // 1e-8 = 1 sat accepted, 1e-9 rejected as Invalid amount + assert_eq!( + sats_from_btc(&json!(1e-8), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc 1e-8 must parse: {err}")), + 1 + ); + assert_eq!( + sats_from_btc(&json!("1e-8"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc 1e-8 must parse: {err}")), + 1 + ); + assert_eq!( + sats_from_btc(&json!("1E-8"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc 1E-8 must parse: {err}")), + 1 + ); + assert!(sats_from_btc(&json!(1e-9), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1e-9"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1E-9"), "Invalid amount").is_err()); + + // Exact 8-decimal satoshi boundary + assert_eq!( + sats_from_btc(&json!("0.00000001"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc boundary must parse: {err}")), + 1 + ); + // More than 8 fractional decimals rejected when they carry value + assert!(sats_from_btc(&json!("0.000000001"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1.000000001"), "Invalid amount").is_err()); + // Trailing zeros normalize away: these are whole satoshis, not + // nine-decimal amounts. + assert_eq!( + sats_from_btc(&json!("1.000000000"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc trailing zeros must parse: {err}")), + 100_000_000 + ); + assert_eq!( + sats_from_btc(&json!("100e-10"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc 100e-10 must parse: {err}")), + 1 + ); + assert_eq!( + sats_from_btc(&json!("0e-100"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc 0e-100 must parse: {err}")), + 0 + ); + assert_eq!( + sats_from_btc(&json!("0.000000000"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc zeros must parse: {err}")), + 0 + ); + // A decimal point needs a digit on each side (Core ParseFixedPoint). + assert!(sats_from_btc(&json!("1."), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!(".1"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("."), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1.2.3"), "Invalid amount").is_err()); + } + + #[test] + fn exact_amount_parsing_handles_zero_sign_and_malformed_inputs() { + use super::sats_from_btc; + + // Zero handling + assert_eq!( + sats_from_btc(&json!(0), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc zero must parse: {err}")), + 0 + ); + assert_eq!( + sats_from_btc(&json!("0"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc zero must parse: {err}")), + 0 + ); + assert_eq!( + sats_from_btc(&json!("0.0"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc zero must parse: {err}")), + 0 + ); + assert_eq!( + sats_from_btc(&json!("0e2147483647"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc zero exp must parse: {err}")), + 0 + ); + + // Optional positive sign accepted, negative sign rejected + assert_eq!( + sats_from_btc(&json!("+1.5"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc +1.5 must parse: {err}")), + 150_000_000 + ); + assert!(sats_from_btc(&json!("-1"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("-0"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("-0.0"), "Invalid amount").is_err()); + + // Non-digits and malformed text rejected (including whitespace) + assert!(sats_from_btc(&json!("abc"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1.2.3"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!(""), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("."), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!(" 1"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1 "), "Invalid amount").is_err()); + } + + #[test] + fn exact_amount_parsing_enforces_overflow_exponent_and_feerate_ceilings() { + use super::{optional_max_feerate, parse_btc_amount, sats_from_btc}; + + // Overflow safety + assert_eq!( + sats_from_btc(&json!("184467440737.09551615"), "Invalid amount") + .unwrap_or_else(|err| panic!("sats_from_btc max must parse: {err}")), + u64::MAX + ); + assert!(sats_from_btc(&json!("184467440737.09551616"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("18446744073709551616"), "Invalid amount").is_err()); + assert!(sats_from_btc(&json!("1e25"), "Invalid amount").is_err()); + // Regression: `digits_len - point` overflows i32 for this input + // (point = -2147483646); it must return `InvalidParams`, not panic + // in an overflow-checking build. + assert!(matches!( + sats_from_btc(&json!("1.1e-2147483647"), "Invalid amount"), + Err(RpcError::InvalidParams("Invalid amount")) + )); + + // parse_btc_amount routes through sats_from_btc + assert_eq!( + parse_btc_amount(&json!("1e-8")) + .unwrap_or_else(|err| panic!("parse_btc_amount 1e-8 must parse: {err}")), + 1 + ); + assert!(parse_btc_amount(&json!("1e-9")).is_err()); + + // optional_max_feerate: 0 is no-cap, 1e-8 accepted, >= 1 BTC/kvB ceiling, negative rejected + assert_eq!( + optional_max_feerate(&json!([0]), 0) + .unwrap_or_else(|err| panic!("optional_max_feerate zero must parse: {err}")), + None + ); + assert_eq!( + optional_max_feerate(&json!(["0"]), 0) + .unwrap_or_else(|err| panic!("optional_max_feerate zero must parse: {err}")), + None + ); + assert_eq!( + optional_max_feerate(&json!(["1e-8"]), 0) + .unwrap_or_else(|err| panic!("optional_max_feerate 1e-8 must parse: {err}")), + Some(1) + ); + assert!(optional_max_feerate(&json!([1.0]), 0).is_err()); + assert!(optional_max_feerate(&json!(["1.0"]), 0).is_err()); + assert!(optional_max_feerate(&json!([-0.1]), 0).is_err()); + assert!(optional_max_feerate(&json!([-1]), 0).is_err()); + assert!(optional_max_feerate(&json!(["-1"]), 0).is_err()); + } } diff --git a/crates/rpc/src/handlers/tx/submit_package.rs b/crates/rpc/src/handlers/tx/submit_package.rs new file mode 100644 index 000000000..187257cad --- /dev/null +++ b/crates/rpc/src/handlers/tx/submit_package.rs @@ -0,0 +1,147 @@ +//! submitpackage wire boundary; admission and all mutations stay in mempool. + +use super::*; +use bitcoin_rs_mempool::{PackageSubmission, PackageTxResult}; + +pub(crate) fn submitpackage(ctx: &Arc, params: &Value) -> Result { + let bound = + crate::handlers::bind_named_params(params, &["package", "maxfeerate", "maxburnamount"])?; + let params = bound.as_ref(); + crate::handlers::ensure_at_most_params(params, 3)?; + let args = params_array(params)?; + let raws = args + .first() + .and_then(|value| value.as_array()) + .ok_or_else(|| RpcError::InvalidType("package must be an array".to_owned()))?; + if raws.is_empty() || raws.len() > bitcoin_rs_mempool::standardness::MAX_PACKAGE_COUNT { + return Err(RpcError::InvalidParameter( + "Array must contain between 1 and 25 transactions.".to_owned(), + )); + } + let max_rate = optional_max_feerate(params, 1)?; + let max_burn = args + .get(2) + .filter(|value| !value.is_null()) + .map(parse_btc_amount) + .transpose()? + .unwrap_or(0); + if max_burn > Amount::MAX_MONEY.to_sat() { + return Err(RpcError::InvalidParameter( + "maxburnamount is out of range".to_owned(), + )); + } + let mut txs = Vec::with_capacity(raws.len()); + for raw in raws { + let raw = raw + .as_str() + .ok_or_else(|| RpcError::InvalidType("raw transaction must be a string".to_owned()))?; + let tx = decode_tx( + raw, + "TX decode failed. Make sure the tx has at least one input.".to_owned(), + )?; + for output in &tx.outputs { + let script = output.script_pubkey.as_bytes(); + // Core's burn guard is per output, not a sum. HasValidOps is + // already owned by the script crate, including malformed pushes. + if (script.len() > bitcoin_rs_script::MAX_SCRIPT_SIZE + || bitcoin_rs_script::is_op_return(script) + || !bitcoin_rs_script::has_valid_ops(script)) + && output.value.to_sat() > max_burn + { + return Err(RpcError::TxVerifyError( + "Unspendable output exceeds maximum configured by user (maxburnamount)" + .to_owned(), + )); + } + } + txs.push(tx); + } + let facts = ctx + .mempool + .gateway + .submit_rpc_package( + &txs, + max_rate, + unix_time_secs(), + &ctx.chain.admission_chain(), + ) + .map_err(|error| match error { + SubmitError::Policy(AcceptanceRejectReason::PackageTopology) => { + RpcError::TxVerifyError( + "package topology disallowed. not child-with-parents or parents depend on each other." + .to_owned(), + ) + } + SubmitError::Policy(AcceptanceRejectReason::PackageUnsupported) => { + RpcError::TxVerifyError(error.to_string()) + } + other => submit_error_to_rpc_error(other), + })?; + // `hashtx` for newly admitted members is published once by the gateway + // observer, in commit order ahead of `rawtx` and the `sequence` `A` event. + render_submission(&txs, facts) +} + +fn package_error(error: SubmitError) -> String { + match error { + SubmitError::Policy(AcceptanceRejectReason::MissingInputs) => { + "bad-txns-inputs-missingorspent".to_owned() + } + SubmitError::Policy(AcceptanceRejectReason::MaxFeeExceeded) => { + "max feerate exceeded".to_owned() + } + SubmitError::Policy(reason) => reject_reason_to_frozen_string(reason), + other => other.to_string(), + } +} + +fn render_submission(txs: &[Tx], facts: PackageSubmission) -> Result { + let failed = facts.tx_results.iter().any(|row| { + matches!( + row, + PackageTxResult::Rejected(_) | PackageTxResult::Unvalidated + ) + }); + let message = match facts.package_error { + Some(AcceptanceRejectReason::MinRelayFeeNotMet) => "transaction failed".to_owned(), + Some(AcceptanceRejectReason::MissingEphemeralSpends) => "unspent-dust".to_owned(), + Some(reason) => reason.to_string(), + None if failed => "transaction failed".to_owned(), + None => "success".to_owned(), + }; + let mut rows = std::collections::BTreeMap::new(); + for (tx, row) in txs.iter().zip(facts.tx_results) { + let mut value = v31::SubmitPackageTxResult { + txid: tx.txid().to_string(), + other_wtxid: None, + vsize: None, + fees: None, + error: None, + }; + match row { + PackageTxResult::Accepted { + vsize, + base_fee, + effective_fee_rate, + effective_includes, + } => { + value.vsize = Some(i64::from(vsize)); + value.fees = Some(v31::SubmitPackageTxResultFees { + base_fee: sat_to_btc(base_fee), + effective_fee_rate: effective_fee_rate.map(sat_to_btc), + effective_includes: effective_fee_rate + .map(|_| effective_includes.iter().map(ToString::to_string).collect()), + }); + } + PackageTxResult::DifferentWitness(other) => value.other_wtxid = Some(other.to_string()), + PackageTxResult::Rejected(error) => value.error = Some(package_error(error)), + PackageTxResult::Unvalidated => value.error = Some("package-not-validated".to_owned()), + } + rows.insert(tx.wtxid().to_string(), value); + } + typed_to_sonic_omitting_nulls(&v31::SubmitPackage { + package_msg: message, + tx_results: rows, + replaced_transactions: Vec::new(), + }) +} diff --git a/crates/rpc/src/handlers/util.rs b/crates/rpc/src/handlers/util.rs index d2ff6b02f..0598eec65 100644 --- a/crates/rpc/src/handlers/util.rs +++ b/crates/rpc/src/handlers/util.rs @@ -71,7 +71,12 @@ pub(crate) fn getrpcinfo(ctx: &Arc, params: &Value) -> Result, + cancelled: impl Fn() -> Result, +) -> Result, RpcError> { + scan_descriptor_range_budgeted(text, network, range, 1024_u64, 1_048_576_u64, cancelled) +} + +/// Expands a scan descriptor over an optional derivation range against remaining caller-supplied budgets. +#[expect( + clippy::too_many_lines, + reason = "descriptor range expansion and budget validation" +)] +pub(crate) fn scan_descriptor_range_budgeted( + text: &str, + network: Network, + range: Option<(u32, u32)>, + remaining_scripts: u64, + remaining_bytes: u64, + cancelled: impl Fn() -> Result, +) -> Result, RpcError> { + let (payload, _) = checked_checksum(text, ChecksumRequirement::Optional)?; + let chain = convert::bitcoin_network(network); + if let Some(raw) = parse_unspendable(payload) { + if remaining_scripts < 1 { + return Err(RpcError::InvalidParameter( + "At most 1024 scan descriptors are supported".into(), + )); + } + let script = match raw.map_err(descriptor_error)? { + Unspendable::Address(address) => address + .require_network(chain) + .map_err(|_| RpcError::InvalidAddressOrKey("Address is not valid".into()))? + .script_pubkey(), + Unspendable::Raw(script) => script, + }; + let script_len = u64::try_from(script.len()) + .map_err(|_| RpcError::InvalidParameter("Scan script byte limit exceeded".into()))?; + if script_len > remaining_bytes { + return Err(RpcError::InvalidParameter( + "Scan script byte limit exceeded".into(), + )); + } + return Ok(vec![ScanScript { + desc: crate::tx_render::script_descriptor(script.as_bytes(), network), + script_pubkey: script.into_bytes(), + }]); + } + // Parsing public keys directly rejects WIF/xprv without converting or + // deriving secrets. Other descriptor endpoints retain their own contract. + let descriptor = MiniscriptDescriptor::::from_str(payload) + .map_err(|error| RpcError::InvalidAddressOrKey(error.to_string()))?; + ensure_keys_match_network(&descriptor, chain).map_err(descriptor_error)?; + if descriptor_needs_private_keys(&descriptor) { + return Err(RpcError::InvalidAddressOrKey( + GENERATEBLOCK_NEEDS_PRIVATE_KEYS.into(), + )); + } + let kind = match &descriptor { + MiniscriptDescriptor::Pkh(_) => "pkh", + MiniscriptDescriptor::Wpkh(_) => "wpkh", + MiniscriptDescriptor::Sh(sh) + if matches!(sh.as_inner(), miniscript::descriptor::ShInner::Wpkh(_)) => + { + "sh(wpkh" + } + MiniscriptDescriptor::Tr(tr) if tr.tap_tree().is_none() => "tr", + _ => { + return Err(RpcError::InvalidParameter( + "Supported scan descriptors are addr, raw, pkh, wpkh, sh(wpkh), and key-only tr" + .into(), + )); + } + }; + let is_range = descriptor.has_wildcard(); + let (begin, end) = if is_range { + range.unwrap_or((0, 1000)) + } else { + (0, 0) + }; + let paths = if descriptor.is_multipath() { + #[expect( + clippy::redundant_clone, + reason = "explicit clone before into_single_descriptors" + )] + descriptor + .clone() + .into_single_descriptors() + .map_err(|error| RpcError::InvalidAddressOrKey(error.to_string()))? + } else { + vec![descriptor] + }; + let paths_len = u64::try_from(paths.len()).map_err(|_| { + RpcError::InvalidParameter("At most 1024 scan descriptors are supported".into()) + })?; + let count = paths_len.saturating_mul( + u64::from(end) + .saturating_sub(u64::from(begin)) + .saturating_add(1), + ); + if count > remaining_scripts { + return Err(RpcError::InvalidParameter( + "At most 1024 scan descriptors are supported".into(), + )); + } + let max_script_size: u64 = match kind { + "pkh" => 25, + "wpkh" => 22, + "sh(wpkh" => 23, + _ => 34, + }; + let preflight_bytes = count.saturating_mul(max_script_size); + if preflight_bytes > remaining_bytes { + return Err(RpcError::InvalidParameter( + "Scan script byte limit exceeded".into(), + )); + } + let count_usize = usize::try_from(count).map_err(|_| { + RpcError::InvalidParameter("At most 1024 scan descriptors are supported".into()) + })?; + let mut scripts = Vec::with_capacity(count_usize); + 'outer: for index in begin..=end { + for path in &paths { + if cancelled()? { + break 'outer; + } + let derived = path + .at_derivation_index(index) + .map_err(|error| RpcError::InvalidAddressOrKey(error.to_string()))?; + let script = scan_key_script(&derived, kind)?; + scripts.push(script); + } + } + Ok(scripts) +} + // --------------------------------------------------------------------------- // Descriptor analysis and derivation (ported from the removed wallet crate). // --------------------------------------------------------------------------- @@ -1507,6 +1657,82 @@ mod tests { ); } + #[test] + fn getrpcinfo_reports_held_calls_oldest_first_and_removes_by_identity() { + let mut ctx = Context::new(); + ctx.debug_log_path = Some(std::path::PathBuf::from("/tmp/debug.log")); + let ctx = Arc::new(ctx); + + let first = ctx.active_calls.begin("getblock"); + let second = ctx.active_calls.begin("getblock"); + let result = + getrpcinfo(&ctx, &json!([])).unwrap_or_else(|err| panic!("getrpcinfo failed: {err}")); + let commands = result + .get("active_commands") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("active_commands missing: {result:?}")); + assert_eq!(commands.len(), 2, "both held calls are active: {result:?}"); + for command in commands { + assert_eq!( + command.get("method").and_then(Value::as_str), + Some("getblock") + ); + assert!( + command + .get("duration") + .and_then(JsonValueTrait::as_u64) + .is_some(), + "duration is elapsed microseconds: {command:?}" + ); + } + + drop(first); + let result = + getrpcinfo(&ctx, &json!([])).unwrap_or_else(|err| panic!("getrpcinfo failed: {err}")); + let commands = result + .get("active_commands") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("active_commands missing: {result:?}")); + assert_eq!( + commands.len(), + 1, + "dropping one guard removes exactly its own entry: {result:?}" + ); + drop(second); + let snapshot = ctx.active_calls.snapshot(); + assert!(snapshot.is_empty(), "all held calls removed: {snapshot:?}"); + } + + #[test] + fn dispatch_registers_the_call_and_unknown_methods_never_register() { + let mut ctx = Context::new(); + ctx.debug_log_path = Some(std::path::PathBuf::from("/tmp/debug.log")); + let handler = crate::handlers::Handler::new(Arc::new(ctx)); + + let result = handler + .dispatch("getrpcinfo", &json!([])) + .unwrap_or_else(|err| panic!("getrpcinfo dispatch failed: {err}")); + let commands = result + .get("active_commands") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("active_commands missing: {result:?}")); + assert_eq!(commands.len(), 1, "getrpcinfo includes itself: {result:?}"); + assert_eq!( + commands[0].get("method").and_then(Value::as_str), + Some("getrpcinfo") + ); + assert!( + handler.context().active_calls.snapshot().is_empty(), + "no residue after the call returns" + ); + + assert!(handler.dispatch("nosuchmethod", &json!([])).is_err()); + assert!( + handler.context().active_calls.snapshot().is_empty(), + "unknown methods never register" + ); + } + #[test] fn getmemoryinfo_reports_resident_bytes_as_the_whole_locked_pool() { use alloc::sync::Arc; diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 7041309d6..42e852af0 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -14,6 +14,8 @@ extern crate alloc; pub mod auth; /// Standard-alphabet Base64 encode/decode for auth headers and PSBTs. mod base64; +/// BIP337 compressed-transaction codec shared by its two RPC adapters. +pub(crate) mod compressed_tx; /// Dependency-injected RPC state. pub mod context; /// Native <-> rust-bitcoin conversions and typed wire serialization. diff --git a/crates/rpc/src/registry.rs b/crates/rpc/src/registry.rs index a8d0ad1ee..5e6abe257 100644 --- a/crates/rpc/src/registry.rs +++ b/crates/rpc/src/registry.rs @@ -192,9 +192,11 @@ declare_rows! { "gettxout", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(tx::gettxout); "gettxoutproof", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(tx::gettxoutproof); "verifytxoutproof", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(tx::verifytxoutproof); - "sendrawtransaction", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Core 31.1 replacement, modified-fee, cluster and TRUC cases are process-verified in overhaul_process_harness::policy_cases. Exact optimal graph ordering does not emulate Core transient SFL work-budget states. Capacity accounting and generic consensus error details retain the differences in docs/policies/mempool-policy.md; aggregate package submission is unsupported.", "0.4.0", Some(tx::sendrawtransaction); - "testmempoolaccept", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Single preview shares committed admission verification. Core 31.1 package shape, dependency, fail-fast and replacement-disallowed cases are process-verified in overhaul_process_harness::policy_cases. Exact graph ordering, capacity behavior and generic error details retain the differences in docs/policies/mempool-policy.md. Aggregate package submission is unsupported.", "0.4.0", Some(tx::testmempoolaccept); + "sendrawtransaction", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Core 31.1 replacement, modified-fee, cluster and TRUC cases are process-verified in overhaul_process_harness::policy_cases. Exact optimal graph ordering does not emulate Core transient SFL work-budget states. Capacity accounting and generic consensus error details retain the differences in docs/policies/mempool-policy.md; scoped package submission is exposed separately by submitpackage.", "0.4.0", Some(tx::sendrawtransaction); + "testmempoolaccept", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Single preview shares committed admission verification. Core 31.1 package shape, dependency, fail-fast and replacement-disallowed cases are process-verified in overhaul_process_harness::policy_cases. Exact graph ordering, capacity behavior and generic error details retain the differences in docs/policies/mempool-policy.md. Scoped package submission is exposed separately by submitpackage.", "0.4.0", Some(tx::testmempoolaccept); "decoderawtransaction", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(tx::decoderawtransaction); + "compressrawtransaction", SurfaceKind::Rpc, Status::Extension, "", "", "Optional BIP337 compressed-transaction encoding; disabled unless the node starts with --compressed-transactions, which answers -32603 (crates/rpc/src/compressed_tx.rs).", "0.14.0", Some(tx::compressrawtransaction), Blocking; + "decompressrawtransaction", SurfaceKind::Rpc, Status::Extension, "", "", "Optional BIP337 compressed-transaction decoding; disabled unless the node starts with --compressed-transactions, which answers -32603 (crates/rpc/src/compressed_tx.rs).", "0.14.0", Some(tx::decompressrawtransaction), Blocking; "createrawtransaction", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Accepts inputs, outputs, locktime and replaceable only. Version is fixed at 2 and replaceable defaults to false. Named version is rejected with -8; a fifth positional or args-prefix entry is rejected with -32602 after named binding. See docs/contracts/external-api.md#api-02-json-rpc-mechanics-and-the-wallet-free-surface.", "0.4.0", Some(tx::createrawtransaction); "combinepsbt", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(tx::combinepsbt); "finalizepsbt", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(tx::finalizepsbt); @@ -204,9 +206,9 @@ declare_rows! { "getrawmempool", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(mempool::getrawmempool); "getmempoolancestors", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(mempool::getmempoolancestors); "getmempooldescendants", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(mempool::getmempooldescendants); - "estimatesmartfee", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "See docs/contracts/external-api.md#API-26 for conf_target and estimate_mode validation. The estimate comes from this node's mempool confirmation-history estimator with a 25-block horizon; estimate_mode is accepted and ignored (no ECONOMICAL/CONSERVATIVE split), and insufficient history returns an `errors` array (crates/rpc/src/handlers/util.rs).", "0.4.0", Some(util::estimatesmartfee); + "estimatesmartfee", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "See docs/contracts/external-api.md#api-26-estimatesmartfee-parameters-and-esplora-fee-estimates for conf_target and estimate_mode validation. The estimate comes from this node's mempool confirmation-history estimator with a 25-block horizon; estimate_mode is accepted and ignored (no ECONOMICAL/CONSERVATIVE split), and insufficient history returns an `errors` array (crates/rpc/src/handlers/util.rs).", "0.4.0", Some(util::estimatesmartfee); "uptime", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "", "0.4.0", Some(util::uptime); - "getrpcinfo", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "active_commands is always an empty array; this node does not track in-flight RPC calls. logpath reports the configured debug log path (crates/rpc/src/handlers/util.rs).", "0.4.0", Some(util::getrpcinfo); + "getrpcinfo", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "active_commands lists calls executing through Handler::dispatch (method and elapsed microseconds, oldest first, including this call); unknown or quota-refused methods never register. logpath reports the configured debug log path (crates/rpc/src/handlers/util.rs).", "0.4.0", Some(util::getrpcinfo); "getmemoryinfo", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "mode=mallocinfo is rejected with an invalid-parameter error instead of returning allocator XML (crates/rpc/src/handlers/util.rs). The figures are resident set size read from the OS, not Core's locked-pool allocator accounting.", "0.4.0", Some(util::getmemoryinfo); "estimaterawfee", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "local_shape: the fee estimator does not expose Core decay/scale/pass/fail internals, so horizon objects carry feerate only and the no-estimate branch stays {} (crates/rpc/src/handlers/util.rs). All three horizons carry the same feerate where Core computes three independent estimates.", "0.4.0", Some(util::estimaterawfee); "getzmqnotifications", SurfaceKind::Rpc, Status::ImplementedUnverified, "zmq", CORE_VERSION, "Requires the zmq feature and --enablezmq* startup flags.", "0.4.0", ZMQ_NOTIFS_HANDLER; @@ -231,10 +233,10 @@ declare_rows! { "submitblock", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Decode failures are -22 (Block decode failed). Extra bytes after a complete block and BIP22's dummy second argument are ignored. A header already admitted by submitheader still accepts the body; a previously connected body (scripts-valid), including after a later reorg, is duplicate.", "0.4.0", Some(mining::submitblock); "submitheader", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "See API-13 in docs/contracts/external-api.md for submitheader behavior.", "0.4.0", Some(mining::submitheader); "prioritisetransaction", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Dummy (params[1]) must be 0 or null; fee_delta is params[2]. Non-zero dummy is Core -8. Pooled dust outputs are -8 except on regtest. See API-23 in docs/contracts/external-api.md for dummy and fee_delta compatibility.", "0.4.0", Some(mining::prioritisetransaction); - "generatetoaddress", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Assembles, solves, and submits n blocks paying the given address through the mining coordinator. Invalid-output behavior is defined in docs/contracts/external-api.md#API-29.", "0.4.0", Some(mining::generatetoaddress); - "generateblock", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Assembles and solves one block paying an address or descriptor from the listed mempool txids or raw txs in that order. See the canonical contract: docs/contracts/external-api.md#API-31.", "0.4.0", Some(mining::generateblock); + "generatetoaddress", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Assembles, solves, and submits n blocks paying the given address through the mining coordinator. Invalid-output behavior is defined in docs/contracts/external-api.md#api-29-generate-invalid-output-text.", "0.4.0", Some(mining::generatetoaddress); + "generateblock", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Assembles and solves one block paying an address or descriptor from the listed mempool txids or raw txs in that order. See the canonical contract: docs/contracts/external-api.md#api-31-generateblock-multipath-ranged-and-expand-errors.", "0.4.0", Some(mining::generateblock); "getnetworkhashps", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Estimated hashes/s over a caller-chosen lookback ending at a caller-chosen height; default lookback 120, height the applied tip.", "0.4.0", Some(mining::getnetworkhashps); - "getprioritisedtransactions", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Projects the mempool's signed fee-delta overlay, including txids not currently pooled. See docs/contracts/external-api.md#API-28 for modified_fee units.", "0.4.0", Some(mining::getprioritisedtransactions); + "getprioritisedtransactions", SurfaceKind::Rpc, Status::ImplementedUnverified, "", CORE_VERSION, "Projects the mempool's signed fee-delta overlay, including txids not currently pooled. See docs/contracts/external-api.md#api-28-getprioritisedtransactions-modified_fee-is-satoshis for modified_fee units.", "0.4.0", Some(mining::getprioritisedtransactions); // -- JSON-RPC: bitcoin-rs extension ------------------------------ "getcapabilities", SurfaceKind::Rpc, Status::Extension, "", CORE_VERSION, "bitcoin-rs reporting of compiled/enabled concrete service capabilities and index lifecycle state (crates/rpc/src/handlers/chain.rs, crates/index/src/capabilities.rs).", "0.4.0", Some(chain::getcapabilities); @@ -274,7 +276,7 @@ declare_rows! { "getprivatebroadcastinfo", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "Private-broadcast store not implemented.", "n/a", None; "joinpsbts", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "PSBT merge not implemented (combine/finalize only).", "n/a", None; "signrawtransactionwithkey", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "Signing requires key material this process never holds.", "n/a", None; - "submitpackage", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "Aggregate CPFP and package RBF submission are intentionally unsupported; multi-row testmempoolaccept implements Core PackageTestAccept. See docs/policies/mempool-policy.md.", "n/a", None; + "submitpackage", SurfaceKind::Rpc, Status::Deviation, "", CORE_VERSION, "Scoped singleton and 1-parent-1-child CPFP submission through the existing atomic gateway, with Core individual-first partial acceptance and resident fee exclusion. Wider packages, replacement and capacity eviction are explicitly unsupported. Generic rejection details retain native differences; see docs/policies/mempool-policy.md.", "0.14.0", Some(tx::submit_package::submitpackage); "utxoupdatepsbt", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "PSBT update from the UTXO set not implemented.", "n/a", None; "enumeratesigners", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "No external signer support.", "n/a", None; "createmultisig", SurfaceKind::Rpc, Status::Unimplemented, "", CORE_VERSION, "No key material (policy).", "n/a", None; diff --git a/crates/rpc/src/server.rs b/crates/rpc/src/server.rs index 5a24c3b56..5ff6982d5 100644 --- a/crates/rpc/src/server.rs +++ b/crates/rpc/src/server.rs @@ -408,7 +408,10 @@ fn serve_connection( return Ok(()); } Err(RequestReadError::Io(error)) => { - let rpc_error = RpcError::InvalidRequest("malformed http request"); + // The JSON-RPC parser renders `InvalidRequest` verbatim for + // Core compatibility; HTTP framing failures keep the local + // prefix this variant used to carry. + let rpc_error = RpcError::InvalidRequest("invalid request: malformed http request"); let response = JsonRpcVersion::Legacy.error_response(&rpc_error, &Value::new_null()); write_json( @@ -866,11 +869,34 @@ enum JsonRpcVersion { } impl JsonRpcVersion { - fn from_request(request: &Value) -> Self { - if request.get("jsonrpc").and_then(Value::as_str) == Some("2.0") { - Self::V2 - } else { - Self::Legacy + /// Classifies the envelope exactly as the pinned Core 31.1 parser does + /// (`src/rpc/request.cpp:206-224`). + /// + /// PRE: `request` is one decoded JSON-RPC request object. + /// POST: `V2` for `"jsonrpc":"2.0"`, `Legacy` for an absent field or the + /// historical `"1.0"` form Core still accepts, and `InvalidRequest` + /// (-32600) with Core's message for a non-string field or any other + /// version string. + /// INVARIANT: classification never executes the method; a refused + /// envelope is answered without dispatch. + fn classify(request: &Value) -> Result { + let Some(version) = request.get("jsonrpc") else { + return Ok(Self::Legacy); + }; + // Core treats an absent field like `null`; only explicit non-strings + // are refused (`request.cpp:213-216`). + if version.is_null() { + return Ok(Self::Legacy); + } + let Some(version) = version.as_str() else { + return Err(RpcError::InvalidRequest("jsonrpc field must be a string")); + }; + match version { + // Core keeps the historical `{"jsonrpc":"1.0"}` request form as + // legacy for backwards compatibility (`request.cpp:216-222`). + "1.0" => Ok(Self::Legacy), + "2.0" => Ok(Self::V2), + _ => Err(RpcError::InvalidRequest("JSON-RPC version not supported")), } } @@ -896,10 +922,34 @@ impl JsonRpcVersion { } } - const fn error_status(self) -> u16 { + /// HTTP status for a failing call in this dialect. + /// + /// Core answers legacy errors with an HTTP error status and v2 errors + /// with HTTP 200 (`src/httprpc.cpp:41-56`). Within the legacy dialect, + /// `RPC_INVALID_REQUEST` maps to 400 and `RPC_METHOD_NOT_FOUND` to 404; + /// every other code keeps 500. + const fn error_status_for(self, error: &RpcError) -> u16 { + self.error_status_for_code(error.code()) + } + + /// The same mapping keyed by the error code, for callers that carry the + /// code rather than the error. + /// + /// For a v2 request only *dispatch* errors stay inside HTTP 200; an + /// envelope violation reaches Core's outer error path, which maps + /// `-32600` to HTTP 400 regardless of the version the parser had + /// already assigned (`src/httprpc.cpp:41-56`, `src/rpc/request.cpp:206-260`). + const fn error_status_for_code(self, code: i64) -> u16 { match self { - Self::Legacy => 500, - Self::V2 => 200, + Self::Legacy => match code { + RpcError::INVALID_REQUEST => 400, + RpcError::METHOD_NOT_FOUND => 404, + _ => 500, + }, + Self::V2 => match code { + RpcError::INVALID_REQUEST => 400, + _ => 200, + }, } } } @@ -908,27 +958,57 @@ enum CallResponse { Reply { body: Value, version: JsonRpcVersion, - is_error: bool, + /// The failing call's error code, when this reply is an error. + /// + /// The legacy HTTP status depends on the code (400 for -32600, 404 + /// for -32601), so the code travels with the reply. + error_code: Option, + /// Whether the error is an envelope refusal raised by the parse + /// step itself. Core maps these `-32600`s to HTTP 400 without + /// consulting the negotiated dialect (`src/httprpc.cpp`), while a + /// method-level error keeps the dialect's own mapping. + envelope_error: bool, }, Notification, } impl CallResponse { - fn reply(body: Value, version: JsonRpcVersion, is_error: bool) -> Self { + fn reply(body: Value, version: JsonRpcVersion, error: Option<&RpcError>) -> Self { + Self::Reply { + body, + version, + error_code: error.map(RpcError::code), + envelope_error: false, + } + } + + /// A reply for a refused envelope: Core raises `-32600` from + /// `JSONRPCRequest::parse` and answers HTTP 400 in either dialect. + fn envelope_error(body: Value, version: JsonRpcVersion, error: &RpcError) -> Self { Self::Reply { body, version, - is_error, + error_code: Some(error.code()), + envelope_error: true, } } const fn http_status(&self) -> u16 { match self { + // The dialect code mapping is the single owner of every reply + // that carries a code, including envelope refusals: Core's + // outer error path maps the post-classification `-32600` to + // 400 through this arm in either dialect. The generic envelope + // arm below only covers the codeless case. Self::Reply { version, - is_error: true, + error_code: Some(code), .. - } => version.error_status(), + } => version.error_status_for_code(*code), + Self::Reply { + envelope_error: true, + .. + } => 400, Self::Reply { .. } | Self::Notification => 200, } } @@ -948,83 +1028,174 @@ fn handle_json(handler: &Handler, body: &[u8]) -> JsonResponse { } }; - let close = request.as_array().map_or_else( - || crate::registry::blocking_request(&request), - |requests| requests.iter().any(crate::registry::blocking_request), - ); - let mut response = handle_decoded_json(handler, &request); - // A rejected long call must not retain the reserved ordinary slot through - // keep-alive. Batch/notification classification uses the same registry. - response.close = close; - response + // The close decision is owned by `handle_decoded_json`, which sees the + // decoded envelope. Undecodable bodies name no method, so they never + // close; every decodable shape carries the flag computed below. + handle_decoded_json(handler, &request) } fn handle_decoded_json(handler: &Handler, request: &Value) -> JsonResponse { + // Single owner of the close decision: a rejected long call must not + // retain the reserved ordinary slot through keep-alive. + // Batch/notification classification uses the same registry, and every + // return below carries this flag, so no caller overwrites it and a + // future `true` cannot be silently discarded. + let close = request.as_array().map_or_else( + || crate::registry::blocking_request(request), + |requests| requests.iter().any(crate::registry::blocking_request), + ); if let Some(requests) = request.as_array() { if requests.is_empty() { - return legacy_error_response( - &RpcError::InvalidRequest("batch must not be empty"), - &Value::new_null(), - ); + // Core: an empty batch still answers HTTP 200 with an empty + // result array (`httprpc.cpp` runs the batch reply path, which + // produces `[]` for no members). + return JsonResponse { + close, + status: 200, + reason: reason_for_status(200), + body: Some(json!([])), + }; } let mut responses = Vec::with_capacity(requests.len()); - let mut status = 200; for request in requests { let response = handle_single_json(handler, request); - status = status.max(response.http_status()); if let CallResponse::Reply { body, .. } = response { responses.push(body); } } if responses.is_empty() { - return no_content_response(); + return if close { + closing_no_content_response() + } else { + no_content_response() + }; } + // Core: "Batches never throw HTTP errors, they are always just + // included in HTTP OK responses" (`httprpc.cpp:200-201`). A + // per-member envelope or dispatch error stays inside the 200 batch. return JsonResponse { - close: false, - status, - reason: reason_for_status(status), + close, + status: 200, + reason: reason_for_status(200), body: Some(json!(responses)), }; } + // A top-level value that is neither an object nor an array is Core's + // `RPC_PARSE_ERROR` "Top-level object parse error" (`httprpc.cpp`, the + // final `else` of `HTTPReq_JSONRPC`), HTTP 500 under the legacy + // mapping. Inside a batch the same value is a per-member -32600. + if !request.is_object() { + return legacy_error_response( + &RpcError::TopLevelParse("Top-level object parse error"), + &Value::new_null(), + ); + } + let response = handle_single_json(handler, request); let status = response.http_status(); match response { CallResponse::Reply { body, .. } => JsonResponse { - close: false, + close, status, reason: reason_for_status(status), body: Some(body), }, - CallResponse::Notification => no_content_response(), + CallResponse::Notification => { + if close { + closing_no_content_response() + } else { + no_content_response() + } + } } } +/// Validates one request object's envelope, then dispatches it. +/// +/// PRE: `request` is one decoded JSON-RPC request value (not a batch). +/// POST: a reply or notification for that member. A malformed envelope is +/// refused with `-32600` before the method is resolved or executed, using +/// the pinned Core 31.1 rules: the `jsonrpc` field, when present, must be +/// a string and must be `"1.0"` or `"2.0"`; `method` must be present and +/// a string; `params`, when present, must be an array, an object, or +/// `null` (`src/rpc/request.cpp:206-260`). A violation while classifying +/// `jsonrpc` answers in the legacy dialect; a later violation keeps the +/// already-assigned dialect's envelope but still takes the legacy HTTP +/// status, matching Core's outer error path (`src/httprpc.cpp:189-194`). +/// INVARIANT: an envelope error never executes the method, and a +/// notification is only silent when its envelope was valid. fn handle_single_json(handler: &Handler, request: &Value) -> CallResponse { let id = request.get("id").cloned().unwrap_or_else(Value::new_null); - let version = JsonRpcVersion::from_request(request); - let Some(method) = request.get("method").and_then(Value::as_str) else { - let error = RpcError::InvalidRequest("method is required"); - return CallResponse::reply(version.error_response(&error, &id), version, true); + if !request.is_object() { + let error = RpcError::InvalidRequest("Invalid Request object"); + let version = JsonRpcVersion::Legacy; + return CallResponse::envelope_error(version.error_response(&error, &id), version, &error); + } + let version = match JsonRpcVersion::classify(request) { + Ok(version) => version, + Err(error) => { + // Core raises -32600 from `JSONRPCRequest::parse`, before the + // claimed version is trusted, so the reply uses the legacy + // envelope (`request.cpp:206-224`) while the HTTP status stays + // the 400 every envelope refusal takes (`httprpc.cpp:41-56`). + // The error envelope still carries the request's `id`. + let version = JsonRpcVersion::Legacy; + return CallResponse::envelope_error( + version.error_response(&error, &id), + version, + &error, + ); + } + }; + // Core checks `isNull()` before `isStr()` (`request.cpp:228-232`), so + // an explicit `"method": null` is "Missing method". + let Some(method) = request.get("method").filter(|method| !method.is_null()) else { + let error = RpcError::InvalidRequest("Missing method"); + return CallResponse::envelope_error(version.error_response(&error, &id), version, &error); + }; + let Some(method) = method.as_str() else { + let error = RpcError::InvalidRequest("Method must be a string"); + return CallResponse::envelope_error(version.error_response(&error, &id), version, &error); }; let null_params = Value::new_null(); let params = request.get("params").unwrap_or(&null_params); + // Core accepts an array or object; `null` (and an absent field) means + // "no positional parameters". Any other JSON type is -32600. + if !(params.is_array() || params.is_object() || params.is_null()) { + let error = RpcError::InvalidRequest("Params must be an array or object"); + return CallResponse::envelope_error(version.error_response(&error, &id), version, &error); + } + // Core canonicalizes absent or explicit-null params to an empty array + // before dispatch, so method defaults apply (`src/rpc/request.cpp:243-248`). + let empty_params = json!([]); + let params = if params.is_null() { + &empty_params + } else { + params + }; let result = handler.dispatch(method, params); if version == JsonRpcVersion::V2 && request.get("id").is_none() { return CallResponse::Notification; } match result { - Ok(result) => CallResponse::reply(version.success_response(&result, &id), version, false), - Err(error) => CallResponse::reply(version.error_response(&error, &id), version, true), + Ok(result) => CallResponse::reply(version.success_response(&result, &id), version, None), + Err(error) => { + CallResponse::reply(version.error_response(&error, &id), version, Some(&error)) + } } } fn legacy_error_response(error: &RpcError, id: &Value) -> JsonResponse { let version = JsonRpcVersion::Legacy; + let status = version.error_status_for(error); JsonResponse { + // Undecodable bodies and top-level scalars name no method, so they + // never close; the flag stays here only because there is no decoded + // envelope for `handle_decoded_json` to classify. close: false, - status: version.error_status(), - reason: reason_for_status(version.error_status()), + status, + reason: reason_for_status(status), body: Some(version.error_response(error, id)), } } @@ -1033,13 +1204,27 @@ const fn reason_for_status(status: u16) -> &'static str { match status { 200 => "OK", 204 => "No Content", + 400 => "Bad Request", + 404 => "Not Found", _ => "Internal Server Error", } } +/// A 204 drain that keeps the connection: the drained batch (or +/// notification) named no blocking method. const fn no_content_response() -> JsonResponse { + no_content_response_inner(false) +} + +/// A 204 drain that also closes: the drained batch (or notification) named +/// a blocking method, so the reserved slot must not survive keep-alive. +const fn closing_no_content_response() -> JsonResponse { + no_content_response_inner(true) +} + +const fn no_content_response_inner(close: bool) -> JsonResponse { JsonResponse { - close: false, + close, status: 204, reason: "No Content", body: None, @@ -1314,1133 +1499,4 @@ struct ResponseHead<'a> { #[cfg(test)] #[expect(clippy::expect_used)] -mod tests { - mod listeners; - - use super::*; - use core::sync::atomic::{AtomicBool, Ordering}; - - use crate::context::Context; - use std::net::{Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream}; - - /// Reads one request the way `serve_connection` does, minus the - /// authentication gate between the two phases. - fn read_whole_request( - reader: &mut BufReader, - head_timeout: Duration, - body_timeout: Duration, - ) -> Result, RequestReadError> { - match read_request_head(reader, head_timeout)? { - Some(head) => read_request_body(reader, body_timeout, head).map(Some), - None => Ok(None), - } - } - - /// What one loopback client saw while `serve_connection` handled its request. - struct Exchange { - /// Bytes received until the server's FIN or a read failure. - response: String, - /// Whether the server closed its write side. A read timeout means it - /// neither answered nor closed. - eof: bool, - /// Time from the first write until the read ended. - elapsed: Duration, - /// The connection handler's own verdict, taken after the client hangs up. - served: io::Result<()>, - } - - /// Serves one loopback connection with `serve_connection`, writes `request` - /// and nothing more, and reports what the client saw. The client keeps its - /// side open until the response ends, as a client still uploading would. - fn exchange(request: &[u8]) -> io::Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - client.set_read_timeout(Some(Duration::from_secs(5)))?; - let server = thread::spawn(move || -> io::Result<()> { - let (stream, _) = listener.accept()?; - serve_connection( - stream, - &Auth::basic("alice", "secret"), - &Handler::new(Arc::new(Context::new())), - false, - Duration::from_secs(30), - RpcSurface::Shared, - ) - }); - let started = Instant::now(); - client.write_all(request)?; - let mut response = Vec::new(); - let mut eof = false; - let mut chunk = [0_u8; 1_024]; - loop { - match client.read(&mut chunk) { - Ok(0) => { - eof = true; - break; - } - Ok(read) => response.extend_from_slice(&chunk[..read]), - Err(_) => break, - } - } - let elapsed = started.elapsed(); - drop(client); - let served = server.join().expect("server thread"); - Ok(Exchange { - response: String::from_utf8_lossy(&response).into_owned(), - eof, - elapsed, - served, - }) - } - - /// `Authorization` tokens for `alice:secret` and `alice:bad`. - const GOOD_TOKEN: &str = "YWxpY2U6c2VjcmV0"; - const BAD_TOKEN: &str = "YWxpY2U6YmFk"; - - /// Core's 401 for JSON-RPC: the challenge, its default error type, an empty - /// body, a close, and no `Date`. - const CORE_UNAUTHORIZED: &str = "HTTP/1.1 401 Unauthorized\r\n\ - WWW-Authenticate: Basic realm=\"jsonrpc\"\r\n\ - Content-Type: text/html; charset=ISO-8859-1\r\n\ - Content-Length: 0\r\n\ - Connection: close\r\n\r\n"; - - fn json_rpc_head(token: Option<&str>, content_length: usize) -> Vec { - let authorization = token.map_or_else(String::new, |token| { - format!("Authorization: Basic {token}\r\n") - }); - format!( - "POST / HTTP/1.1\r\nHost: localhost\r\n{authorization}Content-Length: {content_length}\r\n\r\n" - ) - .into_bytes() - } - - /// A JSON-RPC request without valid credentials is answered from its head: - /// the client withholds all 16 MiB it declared and still gets Core's exact - /// 401 and a FIN. A wrong password waits out Core's brute-force delay first. - #[test] - fn unauthorized_json_rpc_is_answered_from_the_head_with_core_s_tuple() -> io::Result<()> { - for (token, delayed) in [(None, false), (Some(BAD_TOKEN), true)] { - let seen = exchange(&json_rpc_head(token, MAX_BODY_BYTES))?; - assert_eq!(seen.response, CORE_UNAUTHORIZED, "token {token:?}"); - assert!(seen.eof, "the server must close after the 401: {token:?}"); - assert!(seen.served.is_ok(), "a 401 is a served request: {token:?}"); - if delayed { - assert!( - seen.elapsed >= WRONG_CREDENTIALS_DELAY, - "wrong credentials answered in {:?}", - seen.elapsed - ); - } - } - Ok(()) - } - - /// Bytes pipelined behind an unauthorized request are discarded, never - /// served: the one response is the 401. - #[test] - fn unauthorized_request_never_serves_pipelined_bytes() -> io::Result<()> { - let mut request = json_rpc_head(None, 2); - request - .extend_from_slice(b"{}GET /rest/chaininfo.json HTTP/1.1\r\nHost: localhost\r\n\r\n"); - let seen = exchange(&request)?; - assert_eq!(seen.response, CORE_UNAUTHORIZED); - assert!(seen.eof); - Ok(()) - } - - /// A 401 witnesses `authenticate_json_rpc_head` on a real loopback pair: - /// the one declared byte is prebuffered before the call, the client stays - /// open through the drain, and the call consumes the buffer without the - /// one-second budget hold before the client sees the 401 and EOF. - #[test] - fn unauthorized_request_with_buffered_body_returns_without_the_drain_hold() -> io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - client.set_read_timeout(Some(Duration::from_secs(5)))?; - let (server, _) = listener.accept()?; - let mut request = json_rpc_head(None, 1); - request.extend_from_slice(b"X"); - client.write_all(&request)?; - let mut reader = - BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(30))); - let head = match read_request_head(&mut reader, REQUEST_HEAD_TIMEOUT) { - Ok(Some(head)) => head, - Ok(None) => return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "missing head")), - Err(RequestReadError::Io(error)) => return Err(error), - Err(RequestReadError::RestBodyTooLarge) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, "body too large")); - } - }; - assert_eq!(head.content_length, 1); - reader.get_mut().reset_deadline(REQUEST_HEAD_TIMEOUT); - assert_eq!( - reader.fill_buf()?, - b"X".as_slice(), - "declared byte must be prebuffered before the 401" - ); - let started = Instant::now(); - let handled = - authenticate_json_rpc_head(&Auth::basic("alice", "secret"), &mut reader, &head)?; - let elapsed = started.elapsed(); - assert!(handled, "unauthorized head must be handled"); - assert!( - reader.buffer().is_empty(), - "drain must consume the prebuffered byte: {:?}", - reader.buffer() - ); - assert!( - elapsed < DRAIN_BUDGET, - "a buffered body must not wait out the drain budget: {elapsed:?}" - ); - let mut response = String::new(); - client.read_to_string(&mut response)?; - assert_eq!(response, CORE_UNAUTHORIZED); - Ok(()) - } - - /// Runs the unauthorized path on the server side of one loopback pair and - /// returns the client's view of the response plus the next `take` bytes - /// the server reads once the handler returns. The server thread starts - /// first and reads while the client writes, so neither side waits on a - /// buffer the other has not drained. - fn serve_unauthorized(request: &[u8], take: usize) -> io::Result<(String, Vec)> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let addr = listener.local_addr()?; - let server = thread::spawn(move || -> io::Result> { - let (stream, _) = listener.accept()?; - let mut reader = - BufReader::new(RequestDeadlineStream::new(stream, Duration::from_secs(30))); - let head = match read_request_head(&mut reader, REQUEST_HEAD_TIMEOUT) { - Ok(Some(head)) => head, - Ok(None) => { - return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "missing head")); - } - Err(RequestReadError::Io(error)) => return Err(error), - Err(RequestReadError::RestBodyTooLarge) => { - return Err(io::Error::new(io::ErrorKind::InvalidData, "body too large")); - } - }; - let handled = - authenticate_json_rpc_head(&Auth::basic("alice", "secret"), &mut reader, &head)?; - assert!(handled, "unauthorized head must be handled"); - let mut next = vec![0_u8; take]; - reader.read_exact(&mut next)?; - Ok(next) - }); - let mut client = TcpStream::connect(addr)?; - client.set_read_timeout(Some(Duration::from_secs(5)))?; - client.write_all(request)?; - client.shutdown(Shutdown::Write)?; - let mut response = String::new(); - client.read_to_string(&mut response)?; - let next = server.join().expect("server thread")?; - Ok((response, next)) - } - - /// The drain consumes exactly the declared body, even at the cap, so the - /// bytes pipelined behind it are still on the stream for the next read. - #[test] - fn unauthorized_drain_consumes_exactly_the_declared_body() -> io::Result<()> { - let mut request = json_rpc_head(None, MAX_DRAIN_BYTES); - request.extend_from_slice(&vec![b'X'; MAX_DRAIN_BYTES]); - request.extend_from_slice(b"NEXT"); - let (response, next) = serve_unauthorized(&request, 4)?; - assert_eq!(response, CORE_UNAUTHORIZED); - assert_eq!(next, b"NEXT"); - Ok(()) - } - - /// A declared body above the cap is drained only up to the cap: the peer - /// still gets the 401 and a clean close, and the bytes past the cap stay - /// unread on the stream. - #[test] - fn unauthorized_drain_stops_at_the_cap_for_an_oversized_body() -> io::Result<()> { - const TAIL: usize = 4 * 1_024; - let mut request = json_rpc_head(None, MAX_DRAIN_BYTES + TAIL); - request.extend_from_slice(&vec![b'X'; MAX_DRAIN_BYTES]); - request.extend_from_slice(&vec![b'Y'; TAIL]); - let (response, rest) = serve_unauthorized(&request, TAIL)?; - assert_eq!(response, CORE_UNAUTHORIZED); - assert_eq!(rest, vec![b'Y'; TAIL]); - Ok(()) - } - - /// A head that fails HTTP framing is a 400 whether or not the client sent - /// credentials: authentication runs only on a well-framed head. - #[test] - fn head_framing_errors_keep_their_400_without_credentials() -> io::Result<()> { - let oversize = json_rpc_head(None, MAX_BODY_BYTES + 1); - for (name, request) in [ - ( - "chunked", - b"POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" - .to_vec(), - ), - ( - "missing length", - b"POST / HTTP/1.1\r\nHost: localhost\r\n\r\n".to_vec(), - ), - ( - "conflicting length", - b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\nContent-Length: 3\r\n\r\n" - .to_vec(), - ), - ("oversize length", oversize), - ] { - let seen = exchange(&request)?; - assert!( - seen.response.starts_with("HTTP/1.1 400 Bad Request"), - "{name}: {}", - seen.response - ); - } - Ok(()) - } - - /// Valid credentials pass the gate and the body is then read: a body cut - /// short is the framing error it always was, not a 401 and not a hang. - #[test] - fn authorized_request_proceeds_to_body_framing() -> io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - client.set_read_timeout(Some(Duration::from_secs(5)))?; - let server = thread::spawn(move || -> io::Result<()> { - let (stream, _) = listener.accept()?; - serve_connection( - stream, - &Auth::basic("alice", "secret"), - &Handler::new(Arc::new(Context::new())), - false, - Duration::from_secs(30), - RpcSurface::Shared, - ) - }); - let mut request = json_rpc_head(Some(GOOD_TOKEN), 16); - request.extend_from_slice(b"{}"); - client.write_all(&request)?; - client.shutdown(Shutdown::Write)?; - let mut response = Vec::new(); - client.read_to_end(&mut response)?; - let _ignored = server.join(); - let text = String::from_utf8_lossy(&response); - assert!(text.starts_with("HTTP/1.1 400 Bad Request"), "{text}"); - Ok(()) - } - - /// Exercises HTTP demux and framing, including refusal before a declared - /// oversized body is uploaded. No Content-Length-sized allocation is needed. - fn rest_wire_request(request: &[u8], enabled: bool) -> std::io::Result> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - client.set_read_timeout(Some(Duration::from_secs(3)))?; - let (server, _) = listener.accept()?; - let join = std::thread::spawn(move || { - let handler = Handler::new(Arc::new(Context::new())); - serve_connection( - server, - &Auth::basic("user", "pass"), - &handler, - enabled, - Duration::from_secs(2), - RpcSurface::Shared, - ) - }); - client.write_all(request)?; - let mut response = Vec::new(); - client.read_to_end(&mut response)?; - let _result = join.join().expect("server thread"); - Ok(response) - } - - /// A peer continuously writes a byte before the idle timeout expires. - /// Request-line, header, and body parsing must still expire promptly. - fn assert_dripped_request_expires(prefix: &[u8]) -> io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - client.set_nodelay(true)?; - let (server, _) = listener.accept()?; - client.write_all(prefix)?; - let stop = Arc::new(AtomicBool::new(false)); - let stop_writer = Arc::clone(&stop); - let writer = thread::spawn(move || { - for _ in 0..80 { - if stop_writer.load(Ordering::Acquire) || client.write_all(b"x").is_err() { - break; - } - thread::sleep(Duration::from_millis(25)); - } - let _ = client.shutdown(Shutdown::Write); - }); - - let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(3))); - reader.get_mut().reset_deadline(Duration::from_millis(200)); - let start = Instant::now(); - let result = read_whole_request( - &mut reader, - Duration::from_millis(200), - Duration::from_millis(200), - ); - stop.store(true, Ordering::Release); - writer.join().expect("writer thread"); - assert!( - start.elapsed() < Duration::from_millis(1_500), - "request exceeded its wall-clock budget" - ); - match result { - Err(RequestReadError::Io(error)) => assert!( - matches!( - error.kind(), - io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock - ), - "unexpected read error: {error}" - ), - Err(RequestReadError::RestBodyTooLarge) => panic!("unexpected size rejection"), - Ok(_) => panic!("incomplete request was accepted"), - } - Ok(()) - } - - #[test] - fn slow_drip_request_line_headers_and_body_expire() -> io::Result<()> { - for prefix in [ - b"GET /".as_slice(), - b"GET / HTTP/1.1\r\nX-Slow: ".as_slice(), - b"POST / HTTP/1.1\r\nContent-Length: 256\r\n\r\n".as_slice(), - ] { - assert_dripped_request_expires(prefix)?; - } - Ok(()) - } - - /// Waiting for the next keep-alive request runs under the idle deadline; - /// the shorter head budget starts only when the request begins. - #[test] - fn keep_alive_wait_is_not_bounded_by_the_head_budget() -> io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - let (server, _) = listener.accept()?; - let writer = thread::spawn(move || -> io::Result<()> { - thread::sleep(Duration::from_millis(300)); - client.write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") - }); - - let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(3))); - reader.get_mut().reset_deadline(Duration::from_secs(3)); - let result = read_request_head(&mut reader, Duration::from_millis(100)); - writer.join().expect("writer thread")?; - assert!( - matches!(result, Ok(Some(_))), - "a request arriving after the head budget but within the idle timeout is served" - ); - Ok(()) - } - - /// One source filling the blocking quota still has a connection left in - /// its own share for an ordinary call such as status or abort. - #[test] - fn blocking_quota_leaves_the_busiest_source_an_ordinary_slot() { - for max_connections in [1, 2, 4, 5, 128] { - let handler = Handler::new(Arc::new(Context::new())); - handler.configure_blocking_admission(per_source_limit(max_connections)); - let held: Vec<_> = core::iter::from_fn(|| handler.reserve_blocking_request().ok()) - .take(max_connections) - .collect(); - assert_eq!( - held.len() + 1, - per_source_limit(max_connections), - "pool {max_connections}" - ); - } - } - - #[test] - fn connection_admission_reserves_slots_for_other_peers() { - // Loopback gets no special treatment: it is one source like any other. - let source = IpAddr::V4(Ipv4Addr::LOCALHOST); - let other = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)); - let mut active = ActiveConnections::default(); - for _ in 0..96 { - assert!(active.admit(source, 128)); - } - assert!( - !active.admit(source, 128), - "a quarter of the pool stays open to other sources" - ); - for _ in 0..32 { - assert!(active.admit(other, 128)); - } - assert!(!active.admit(other, 128), "the pool is full"); - active.release(source); - assert!(active.admit(source, 128)); - - let mut small_pool = ActiveConnections::default(); - for _ in 0..3 { - assert!(small_pool.admit(source, 4)); - } - assert!(!small_pool.admit(source, 4)); - assert!(small_pool.admit(other, 4)); - - // Below four workers no share is kept: one client can hold a blocking - // call and still reach the ordinary slot the quota leaves it. - let mut two_slots = ActiveConnections::default(); - assert!(two_slots.admit(source, 2)); - assert!(two_slots.admit(source, 2)); - assert!(!two_slots.admit(other, 2), "the pool is full"); - } - - #[test] - fn getutxos_post_preserves_http_security_and_limits() -> std::io::Result<()> { - for (path, enabled, expected) in [ - ("/rest/getutxos.bin", true, 200), - ("/rest/getutxos.bin", false, 404), - ("/rest/chaininfo.json", true, 404), - ("/rest/getutxos-extra.bin", true, 404), - ("/", true, 401), - ] { - let mut request = format!("POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\nConnection: close\r\n\r\n").into_bytes(); - request.extend_from_slice(&[0, 0]); - let response = rest_wire_request(&request, enabled)?; - assert!(response.starts_with(format!("HTTP/1.1 {expected} ").as_bytes())); - assert!(!String::from_utf8_lossy(&response).contains("Access-Control-Allow-Origin")); - } - for framing in [ - "Content-Length: 2\r\nContent-Length: 3\r\n", - "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", - "", - ] { - let request = format!( - "POST /rest/getutxos.bin HTTP/1.1\r\nHost: localhost\r\n{framing}Connection: close\r\n\r\n" - ); - assert!(rest_wire_request(request.as_bytes(), true)?.starts_with(b"HTTP/1.1 400 ")); - } - for (enabled, expected) in [(true, 400), (false, 404)] { - // Deliberately send no body: receiving an answer proves the cap - // was enforced before the body read. - let response = rest_wire_request(b"POST /rest/getutxos.hex?cache=1 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2049\r\nConnection: keep-alive\r\n\r\n", enabled)?; - assert!(response.starts_with(format!("HTTP/1.1 {expected} ").as_bytes())); - let text = String::from_utf8_lossy(&response); - assert!(text.contains("Connection: close")); - assert!(text.contains("Content-Type: text/plain")); - } - Ok(()) - } - - #[test] - fn prepare_http_socket_disables_nagle() { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind"); - let addr = listener.local_addr().expect("local_addr"); - let client = TcpStream::connect(addr).expect("connect"); - let (server, _) = listener.accept().expect("accept"); - let client = prepare_http_socket(client).expect("configure client"); - let server = prepare_http_socket(server).expect("configure server"); - assert!(client.nodelay().expect("client nodelay")); - assert!(server.nodelay().expect("server nodelay")); - } - - #[test] - #[expect(clippy::expect_used)] - fn serve_with_shutdown_exits_on_signal() -> std::io::Result<()> { - let auth = Arc::new(Auth::basic("alice", "secret")); - let handler = Arc::new(Handler::new(Arc::new(Context::new()))); - let server = RpcServer::bind( - "127.0.0.1:0", - auth, - handler, - 4, - core::time::Duration::from_millis(500), - false, - )?; - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_clone = Arc::clone(&shutdown); - let handle = std::thread::spawn(move || server.serve_with_shutdown(shutdown_clone)); - std::thread::sleep(core::time::Duration::from_millis(150)); - shutdown.store(true, Ordering::Release); - handle.join().expect("join serve thread") - } - - fn accept_test_server() -> io::Result { - RpcServer::bind( - "127.0.0.1:0", - Arc::new(Auth::basic("alice", "secret")), - Arc::new(Handler::new(Arc::new(Context::new()))), - 4, - Duration::from_secs(1), - false, - ) - } - - // Inject at the syscall boundary, without exhausting the test process's - // descriptors. The listener and subsequent HTTP worker remain real. - #[test] - fn accept_recovers_from_resource_and_connection_errors() -> io::Result<()> { - let server = accept_test_server()?; - let addr = server.local_addr()?; - let shutdown = Arc::new(AtomicBool::new(false)); - let stop = Arc::clone(&shutdown); - let handle = thread::spawn(move || { - let mut errors = vec![ - io::Error::from(io::ErrorKind::Interrupted), - io::Error::from(io::ErrorKind::ConnectionAborted), - io::Error::from(io::ErrorKind::ConnectionReset), - io::Error::from(io::ErrorKind::PermissionDenied), - io::Error::from(io::ErrorKind::Unsupported), - io::Error::other("pending protocol error"), - ]; - #[cfg(unix)] - errors.extend([ - io::Error::from_raw_os_error(rustix::io::Errno::MFILE.raw_os_error()), - io::Error::from_raw_os_error(rustix::io::Errno::NFILE.raw_os_error()), - ]); - #[cfg(target_os = "linux")] - { - let error = - io::Error::from_raw_os_error(rustix::io::Errno::OPNOTSUPP.raw_os_error()); - assert_eq!(error.kind(), io::ErrorKind::Unsupported); - errors.push(error); - } - #[cfg(windows)] - errors.push(io::Error::from_raw_os_error(10024)); // WSAEMFILE - let mut errors = errors.into_iter(); - let start = Instant::now(); - server.serve_accept_loop(&stop.into(), RpcSurface::Shared, |listener| { - if let Some(error) = errors.next() { - return Err(error); - } - assert!(start.elapsed() >= POLL_INTERVAL * 3); - listener.accept() - }) - }); - let response = (|| -> io::Result { - let mut client = TcpStream::connect(addr)?; - client.set_read_timeout(Some(Duration::from_secs(5)))?; - client.write_all( - b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", - )?; - let mut response = String::new(); - client.read_to_string(&mut response)?; - Ok(response) - })(); - shutdown.store(true, Ordering::Release); - handle.join().expect("accept thread")?; - assert!(response?.starts_with("HTTP/1.1 401")); - Ok(()) - } - - #[test] - fn repeated_accept_errors_back_off_and_observe_shutdown() -> io::Result<()> { - let server = accept_test_server()?; - let shutdown = Arc::new(AtomicBool::new(false)); - let stop = Arc::clone(&shutdown); - let start = Instant::now(); - let mut attempts = 0; - server.serve_accept_loop(&shutdown.into(), RpcSurface::Shared, |_| { - attempts += 1; - if attempts == 3 { - stop.store(true, Ordering::Release); - } - Err(io::Error::other("resource temporarily exhausted")) - })?; - assert_eq!(attempts, 3); - assert!(start.elapsed() >= POLL_INTERVAL * 3); - Ok(()) - } - - #[test] - fn invalid_listener_errors_are_returned() -> io::Result<()> { - for kind in [io::ErrorKind::InvalidInput, io::ErrorKind::NotConnected] { - let server = accept_test_server()?; - let shutdown = Arc::new(AtomicBool::new(false)); - let mut attempts = 0; - let result = server.serve_accept_loop(&shutdown.into(), RpcSurface::Shared, |_| { - attempts += 1; - Err(kind.into()) - }); - assert_eq!(result.expect_err("fatal listener error").kind(), kind); - assert_eq!(attempts, 1); - } - Ok(()) - } - - #[test] - fn split_path_query_preserves_path_and_query() { - assert_eq!( - split_path_query("/rest/headers/hash.json?count=10"), - ("/rest/headers/hash.json", "count=10") - ); - assert_eq!( - split_path_query("/rest/chaininfo.json"), - ("/rest/chaininfo.json", "") - ); - } - - #[test] - fn wf_02_classify_splits_rest_esplora_and_json_rpc() { - assert_eq!( - classify("GET", "/rest/chaininfo.json"), - HttpRoute::Rest { - path: "/rest/chaininfo.json", - query: "" - } - ); - assert_eq!( - classify("GET", "/api/blocks/tip/height"), - HttpRoute::EsploraGet { - surface: HttpSurface::EsploraPublic, - path: "/blocks/tip/height", - query: "" - } - ); - assert_eq!( - classify("GET", "/esplora/internal/mempool/txs?max_txs=1"), - HttpRoute::EsploraGet { - surface: HttpSurface::EsploraBackend, - path: "/internal/mempool/txs", - query: "max_txs=1" - } - ); - assert_eq!( - classify("POST", "/api/tx"), - HttpRoute::EsploraPost { - surface: HttpSurface::EsploraPublic, - path: "/tx" - } - ); - assert_eq!( - classify("POST", "/esplora/internal/txs"), - HttpRoute::EsploraPost { - surface: HttpSurface::EsploraBackend, - path: "/internal/txs" - } - ); - assert_eq!( - classify("GET", "/blocks/tip/height"), - HttpRoute::NotFound, - "unprefixed GET is not Esplora" - ); - assert_eq!( - classify("HEAD", "/api/tx"), - HttpRoute::NotFound, - "HEAD /api/tx must not run POST /tx" - ); - assert_eq!( - classify("PUT", "/"), - HttpRoute::NotFound, - "non-POST methods are not JSON-RPC" - ); - assert_eq!( - classify("GET", "/api/v1/block-height/0"), - HttpRoute::EsploraGet { - surface: HttpSurface::EsploraPublic, - path: "/v1/block-height/0", - query: "" - }, - "GET /api/v1 stays in the closed /api directory (404 inside Esplora)" - ); - assert_eq!( - classify("POST", "/api/v1/tx"), - HttpRoute::EsploraPost { - surface: HttpSurface::EsploraPublic, - path: "/v1/tx" - } - ); - assert_eq!( - classify("OPTIONS", "/api/blocks/tip/height"), - HttpRoute::Preflight { - surface: HttpSurface::EsploraPublic - } - ); - assert_eq!( - classify("OPTIONS", "/esplora/internal/txs"), - HttpRoute::NotFound - ); - assert_eq!( - classify("OPTIONS", "/rest/chaininfo.json"), - HttpRoute::NotFound - ); - assert_eq!(classify("OPTIONS", "/"), HttpRoute::NotFound); - assert_eq!(classify("POST", "/"), HttpRoute::JsonRpc); - assert_eq!(classify("POST", "/tx"), HttpRoute::JsonRpc); - assert_eq!(classify("GET", "/blocks/tip/height"), HttpRoute::NotFound); - } - - /// WF-02: a body-carrying POST without Content-Length is a framing - /// error, not an empty JSON-RPC request. - #[test] - fn post_without_content_length_is_a_framing_error() -> std::io::Result<()> { - let auth = Arc::new(Auth::basic("alice", "secret")); - let handler = Arc::new(Handler::new(Arc::new(Context::new()))); - let server = RpcServer::bind( - "127.0.0.1:0", - auth, - handler, - 4, - core::time::Duration::from_millis(500), - false, - )?; - let addr = server.listener.local_addr()?; - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_clone = Arc::clone(&shutdown); - let handle = std::thread::spawn(move || server.serve_with_shutdown(shutdown_clone)); - - let mut stream = TcpStream::connect(addr)?; - write!( - stream, - "POST / HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n" - )?; - stream.flush()?; - let mut buf = Vec::new(); - stream.read_to_end(&mut buf)?; - let text = String::from_utf8_lossy(&buf); - let code: u16 = text - .split_whitespace() - .nth(1) - .and_then(|token| token.parse().ok()) - .expect("HTTP status"); - // The connection-level framing error surfaces as a bad-request-class - // response before any JSON-RPC authentication or parsing runs. - assert!( - (400..500).contains(&code) && code != 401, - "expected a framing error status, got {code}" - ); - - shutdown.store(true, Ordering::SeqCst); - drop(handle.join().expect("server thread")); - Ok(()) - } - - /// Runs the head and body reads over a loopback socket fed `request` in writes of - /// at most `chunk` bytes, after which the client closes its write half. - fn read_fed_request( - request: Vec, - chunk: usize, - ) -> io::Result, RequestReadError>> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let mut client = TcpStream::connect(listener.local_addr()?)?; - let (server, _) = listener.accept()?; - let writer = thread::spawn(move || -> io::Result<()> { - for piece in request.chunks(chunk) { - client.write_all(piece)?; - } - client.shutdown(Shutdown::Write) - }); - let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(3))); - let result = - read_whole_request(&mut reader, Duration::from_secs(3), Duration::from_secs(3)); - // Close the server end first: after a failed read, a writer blocked on - // a full socket buffer would otherwise never return. The writer's - // resulting error must not hide the read's. - drop(reader); - let written = writer.join().expect("writer thread"); - if result.is_ok() { - written?; - } - Ok(result) - } - - #[test] - fn body_arriving_across_reads_is_received_whole() -> io::Result<()> { - let body = b"0123456789abcdef".repeat(4_096); - let mut request = format!( - "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", - body.len() - ) - .into_bytes(); - request.extend_from_slice(&body); - let Ok(Some(received)) = read_fed_request(request, 1_000)? else { - panic!("a complete request must decode"); - }; - assert_eq!(received.body, body); - Ok(()) - } - - /// A body declared at the size cap but cut short is a framing error, as - /// before. - #[test] - fn declared_but_unsent_body_is_a_framing_error() -> io::Result<()> { - let request = format!( - "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: {MAX_BODY_BYTES}\r\n\r\npartial" - ); - match read_fed_request(request.into_bytes(), usize::MAX)? { - Err(RequestReadError::Io(error)) => { - assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); - } - Err(RequestReadError::RestBodyTooLarge) => panic!("unexpected size rejection"), - Ok(_) => panic!("a truncated body was accepted"), - } - Ok(()) - } - - /// The body buffer tracks the bytes that arrived, not the declared length. - #[test] - fn declared_but_unsent_body_allocates_only_received_bytes() { - let mut body = Vec::new(); - let result = read_body( - &mut std::io::Cursor::new(b"partial"), - MAX_BODY_BYTES, - &mut body, - ); - assert!( - matches!(&result, Err(error) if error.kind() == io::ErrorKind::UnexpectedEof), - "{result:?}" - ); - assert_eq!(body, b"partial"); - assert!( - body.capacity() < 64 * 1_024, - "a {MAX_BODY_BYTES}-byte declaration reserved {} bytes", - body.capacity() - ); - } - - /// A body received in full keeps a buffer no larger than its declared - /// length, including at the size cap and at lengths that are not powers - /// of two. - #[test] - fn full_body_capacity_stays_within_the_declared_length() -> io::Result<()> { - for content_length in [MAX_BODY_BYTES, 9_000_000] { - let mut request = format!( - "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: {content_length}\r\n\r\n" - ) - .into_bytes(); - request.resize(request.len() + content_length, b'x'); - let Ok(Some(received)) = read_fed_request(request, 1_460)? else { - panic!("a complete request must decode"); - }; - assert_eq!(received.body.len(), content_length); - assert!( - received.body.capacity() <= content_length, - "a {content_length}-byte body kept {} bytes", - received.body.capacity() - ); - } - Ok(()) - } - - /// The request-head budget must be enforced while bytes arrive, even if - /// the peer never sends a newline or closes its write half. - #[test] - fn oversized_unterminated_head_is_rejected_before_newline() -> std::io::Result<()> { - for prefix in [ - b"GET /".as_slice(), - b"GET / HTTP/1.1\r\nX-Fill: ".as_slice(), - ] { - let listener = TcpListener::bind("127.0.0.1:0")?; - let address = listener.local_addr()?; - let auth = Arc::new(Auth::basic("alice", "secret")); - let handler = Arc::new(Handler::new(Arc::new(Context::new()))); - let server = std::thread::spawn(move || { - let (stream, _) = listener.accept().expect("accept request"); - serve_connection( - stream, - &auth, - &handler, - false, - core::time::Duration::from_secs(5), - RpcSurface::Shared, - ) - }); - - let mut client = TcpStream::connect(address)?; - client.set_read_timeout(Some(core::time::Duration::from_secs(2)))?; - client.write_all(prefix)?; - client.write_all(&vec![b'a'; MAX_HEADER_BYTES])?; - - let mut response = [0_u8; 128]; - let first_read = client.read(&mut response); - client.shutdown(Shutdown::Write)?; - let _ = server.join().expect("server thread"); - let read = first_read?; - assert!( - response[..read].starts_with(b"HTTP/1.1 400 Bad Request"), - "oversized head must fail before newline: {prefix:?}" - ); - } - Ok(()) - } - - #[test] - fn exact_request_head_budget_is_accepted() -> std::io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let address = listener.local_addr()?; - let auth = Arc::new(Auth::basic("alice", "secret")); - let handler = Arc::new(Handler::new(Arc::new(Context::new()))); - let server = std::thread::spawn(move || { - let (stream, _) = listener.accept().expect("accept request"); - serve_connection( - stream, - &auth, - &handler, - false, - core::time::Duration::from_secs(5), - RpcSurface::Shared, - ) - }); - - let mut request = b"GET / HTTP/1.1\r\nX-Fill: ".to_vec(); - request.extend(vec![ - b'a'; - MAX_HEADER_BYTES - request.len() - b"\r\n\r\n".len() - ]); - request.extend_from_slice(b"\r\n\r\n"); - assert_eq!(request.len(), MAX_HEADER_BYTES); - let mut client = TcpStream::connect(address)?; - client.write_all(&request)?; - let mut response = String::new(); - client.read_to_string(&mut response)?; - assert!(response.starts_with("HTTP/1.1 404 Not Found")); - server.join().expect("server thread")?; - Ok(()) - } - - #[test] - fn listener_directory_table_is_closed_over_http() -> std::io::Result<()> { - let auth = Arc::new(Auth::basic("alice", "secret")); - let handler = Arc::new(Handler::new(Arc::new(Context::new()))); - let server = RpcServer::bind( - "127.0.0.1:0", - auth, - handler, - 4, - core::time::Duration::from_millis(500), - false, - )?; - let addr = server.listener.local_addr()?; - let shutdown = Arc::new(AtomicBool::new(false)); - let shutdown_clone = Arc::clone(&shutdown); - let handle = std::thread::spawn(move || server.serve_with_shutdown(shutdown_clone)); - - let status = |method: &str, path: &str| -> std::io::Result { - let mut stream = TcpStream::connect(addr)?; - write!( - stream, - "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - )?; - stream.flush()?; - let mut buf = Vec::new(); - stream.read_to_end(&mut buf)?; - let text = String::from_utf8_lossy(&buf); - let code = text - .split_whitespace() - .nth(1) - .and_then(|token| token.parse().ok()) - .expect("HTTP status"); - Ok(code) - }; - - assert_eq!(status("GET", "/blocks/tip/height")?, 404); - assert_eq!(status("HEAD", "/api/tx")?, 404); - assert_eq!(status("PUT", "/")?, 404); - assert_eq!(status("POST", "/")?, 401); - assert_eq!(status("GET", "/api/blocks/tip/height")?, 200); - - shutdown.store(true, Ordering::Release); - handle.join().expect("join serve thread")?; - Ok(()) - } - - #[test] - fn unsupported_method_is_404_at_listener() -> std::io::Result<()> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let address = listener.local_addr()?; - let auth = Arc::new(Auth::basic("alice", "secret")); - let handler = Arc::new(Handler::new(Arc::new(Context::new()))); - let thread = std::thread::spawn(move || { - let (stream, _) = listener.accept().expect("accept request"); - serve_connection( - stream, - &auth, - &handler, - false, - core::time::Duration::from_secs(1), - RpcSurface::Shared, - ) - }); - - let mut client = TcpStream::connect(address)?; - client.write_all(b"HEAD /api/tx HTTP/1.1\r\nHost: localhost\r\n\r\n")?; - let mut response = String::new(); - client.read_to_string(&mut response)?; - assert!(response.starts_with("HTTP/1.1 404 Not Found")); - thread - .join() - .expect("listener thread") - .expect("serve request"); - Ok(()) - } - - #[test] - fn json_rpc_2_success_omits_null_error_for_jsonrpsee_clients() { - let handler = Handler::new(Arc::new(Context::new())); - let response = handle_json( - &handler, - br#"{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}"#, - ); - let response = response.body.expect("JSON-RPC response body"); - - assert_eq!(response.get("jsonrpc").and_then(Value::as_str), Some("2.0")); - assert!(response.get("result").is_some()); - assert!(response.get("error").is_none()); - } - - #[test] - fn bitcoin_core_1_success_keeps_null_error() { - let handler = Handler::new(Arc::new(Context::new())); - let response = handle_json( - &handler, - br#"{"jsonrpc":"1.0","id":1,"method":"getblockchaininfo","params":[]}"#, - ); - let response = response.body.expect("JSON-RPC response body"); - - assert!(response.get("jsonrpc").is_none()); - assert!(response.get("result").is_some()); - assert!(response.get("error").is_some_and(Value::is_null)); - } - - #[test] - fn json_rpc_2_error_omits_result_and_uses_http_200() { - let handler = Handler::new(Arc::new(Context::new())); - let response = handle_json( - &handler, - br#"{"jsonrpc":"2.0","id":7,"method":"missing","params":[]}"#, - ); - let status = response.status; - let body = response.body.expect("JSON-RPC response body"); - - assert_eq!(status, 200); - assert!(body.get("result").is_none()); - assert!(body.get("error").is_some_and(Value::is_object)); - assert_eq!(body.get("id").and_then(Value::as_i64), Some(7)); - } - - #[test] - fn json_rpc_2_notification_has_no_response_body() { - let handler = Handler::new(Arc::new(Context::new())); - let response = handle_json( - &handler, - br#"{"jsonrpc":"2.0","method":"getblockcount","params":[]}"#, - ); - - assert!(response.body.is_none()); - } - - #[test] - fn json_rpc_batch_excludes_notifications() { - let handler = Handler::new(Arc::new(Context::new())); - let response = handle_json( - &handler, - br#"[ - {"jsonrpc":"2.0","id":1,"method":"getblockcount","params":[]}, - {"jsonrpc":"2.0","method":"getblockcount","params":[]}, - {"jsonrpc":"2.0","id":2,"method":"missing","params":[]} - ]"#, - ); - let status = response.status; - let body = response.body.expect("JSON-RPC batch response body"); - let rows = body.as_array().expect("batch response array"); - - assert_eq!(status, 200); - assert_eq!(rows.len(), 2); - assert!(rows[0].get("result").is_some()); - assert!(rows[1].get("error").is_some()); - } -} +mod tests; diff --git a/crates/rpc/src/server/tests.rs b/crates/rpc/src/server/tests.rs new file mode 100644 index 000000000..0fab6c23e --- /dev/null +++ b/crates/rpc/src/server/tests.rs @@ -0,0 +1,1564 @@ +mod listeners; + +use super::*; +use core::sync::atomic::{AtomicBool, Ordering}; + +use crate::context::Context; +use std::net::{Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream}; + +/// Exercises HTTP demux and framing, including refusal before a declared +/// oversized body is uploaded. No Content-Length-sized allocation is needed. +fn rest_wire_request(request: &[u8], enabled: bool) -> std::io::Result> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + client.set_read_timeout(Some(Duration::from_secs(3)))?; + let (server, _) = listener.accept()?; + let join = std::thread::spawn(move || { + let handler = Handler::new(Arc::new(Context::new())); + serve_connection( + server, + &Auth::basic("user", "pass"), + &handler, + enabled, + Duration::from_secs(2), + RpcSurface::Shared, + ) + }); + client.write_all(request)?; + let mut response = Vec::new(); + client.read_to_end(&mut response)?; + let _result = join.join().expect("server thread"); + Ok(response) +} + +/// A peer continuously writes a byte before the idle timeout expires. +/// Request-line, header, and body parsing must still expire promptly. +fn assert_dripped_request_expires(prefix: &[u8]) -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + client.set_nodelay(true)?; + let (server, _) = listener.accept()?; + client.write_all(prefix)?; + let stop = Arc::new(AtomicBool::new(false)); + let stop_writer = Arc::clone(&stop); + let writer = thread::spawn(move || { + for _ in 0..80 { + if stop_writer.load(Ordering::Acquire) || client.write_all(b"x").is_err() { + break; + } + thread::sleep(Duration::from_millis(25)); + } + let _ = client.shutdown(Shutdown::Write); + }); + + let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(3))); + reader.get_mut().reset_deadline(Duration::from_millis(200)); + let start = Instant::now(); + let result = read_whole_request( + &mut reader, + Duration::from_millis(200), + Duration::from_millis(200), + ); + stop.store(true, Ordering::Release); + writer.join().expect("writer thread"); + assert!( + start.elapsed() < Duration::from_millis(1_500), + "request exceeded its wall-clock budget" + ); + match result { + Err(RequestReadError::Io(error)) => assert!( + matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ), + "unexpected read error: {error}" + ), + Err(RequestReadError::RestBodyTooLarge) => panic!("unexpected size rejection"), + Ok(_) => panic!("incomplete request was accepted"), + } + Ok(()) +} + +#[test] +fn slow_drip_request_line_headers_and_body_expire() -> io::Result<()> { + for prefix in [ + b"GET /".as_slice(), + b"GET / HTTP/1.1\r\nX-Slow: ".as_slice(), + b"POST / HTTP/1.1\r\nContent-Length: 256\r\n\r\n".as_slice(), + ] { + assert_dripped_request_expires(prefix)?; + } + Ok(()) +} + +/// Waiting for the next keep-alive request runs under the idle deadline; +/// the shorter head budget starts only when the request begins. +#[test] +fn keep_alive_wait_is_not_bounded_by_the_head_budget() -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + let (server, _) = listener.accept()?; + let writer = thread::spawn(move || -> io::Result<()> { + thread::sleep(Duration::from_millis(300)); + client.write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n") + }); + + let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(3))); + reader.get_mut().reset_deadline(Duration::from_secs(3)); + let result = read_whole_request( + &mut reader, + Duration::from_millis(100), + Duration::from_millis(100), + ); + writer.join().expect("writer thread")?; + assert!( + matches!(result, Ok(Some(_))), + "a request arriving after the head budget but within the idle timeout is served" + ); + Ok(()) +} + +/// One source filling the blocking quota still has a connection left in +/// its own share for an ordinary call such as status or abort. +#[test] +fn blocking_quota_leaves_the_busiest_source_an_ordinary_slot() { + for max_connections in [1, 2, 4, 5, 128] { + let handler = Handler::new(Arc::new(Context::new())); + handler.configure_blocking_admission(per_source_limit(max_connections)); + let held: Vec<_> = core::iter::from_fn(|| handler.reserve_blocking_request().ok()) + .take(max_connections) + .collect(); + assert_eq!( + held.len() + 1, + per_source_limit(max_connections), + "pool {max_connections}" + ); + } +} + +#[test] +fn connection_admission_reserves_slots_for_other_peers() { + // Loopback gets no special treatment: it is one source like any other. + let source = IpAddr::V4(Ipv4Addr::LOCALHOST); + let other = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)); + let mut active = ActiveConnections::default(); + for _ in 0..96 { + assert!(active.admit(source, 128)); + } + assert!( + !active.admit(source, 128), + "a quarter of the pool stays open to other sources" + ); + for _ in 0..32 { + assert!(active.admit(other, 128)); + } + assert!(!active.admit(other, 128), "the pool is full"); + active.release(source); + assert!(active.admit(source, 128)); + + let mut small_pool = ActiveConnections::default(); + for _ in 0..3 { + assert!(small_pool.admit(source, 4)); + } + assert!(!small_pool.admit(source, 4)); + assert!(small_pool.admit(other, 4)); + + // Below four workers no share is kept: one client can hold a blocking + // call and still reach the ordinary slot the quota leaves it. + let mut two_slots = ActiveConnections::default(); + assert!(two_slots.admit(source, 2)); + assert!(two_slots.admit(source, 2)); + assert!(!two_slots.admit(other, 2), "the pool is full"); +} + +#[test] +fn getutxos_post_preserves_http_security_and_limits() -> std::io::Result<()> { + for (path, enabled, expected) in [ + ("/rest/getutxos.bin", true, 200), + ("/rest/getutxos.bin", false, 404), + ("/rest/chaininfo.json", true, 404), + ("/rest/getutxos-extra.bin", true, 404), + ("/", true, 401), + ] { + let mut request = format!("POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\nConnection: close\r\n\r\n").into_bytes(); + request.extend_from_slice(&[0, 0]); + let response = rest_wire_request(&request, enabled)?; + assert!(response.starts_with(format!("HTTP/1.1 {expected} ").as_bytes())); + assert!(!String::from_utf8_lossy(&response).contains("Access-Control-Allow-Origin")); + } + for framing in [ + "Content-Length: 2\r\nContent-Length: 3\r\n", + "Content-Length: 2\r\nTransfer-Encoding: chunked\r\n", + "", + ] { + let request = format!( + "POST /rest/getutxos.bin HTTP/1.1\r\nHost: localhost\r\n{framing}Connection: close\r\n\r\n" + ); + assert!(rest_wire_request(request.as_bytes(), true)?.starts_with(b"HTTP/1.1 400 ")); + } + for (enabled, expected) in [(true, 400), (false, 404)] { + // Deliberately send no body: receiving an answer proves the cap + // was enforced before the body read. + let response = rest_wire_request(b"POST /rest/getutxos.hex?cache=1 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2049\r\nConnection: keep-alive\r\n\r\n", enabled)?; + assert!(response.starts_with(format!("HTTP/1.1 {expected} ").as_bytes())); + let text = String::from_utf8_lossy(&response); + assert!(text.contains("Connection: close")); + assert!(text.contains("Content-Type: text/plain")); + } + Ok(()) +} + +#[test] +fn prepare_http_socket_disables_nagle() { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).expect("bind"); + let addr = listener.local_addr().expect("local_addr"); + let client = TcpStream::connect(addr).expect("connect"); + let (server, _) = listener.accept().expect("accept"); + let client = prepare_http_socket(client).expect("configure client"); + let server = prepare_http_socket(server).expect("configure server"); + assert!(client.nodelay().expect("client nodelay")); + assert!(server.nodelay().expect("server nodelay")); +} + +#[test] +#[expect(clippy::expect_used)] +fn serve_with_shutdown_exits_on_signal() -> std::io::Result<()> { + let auth = Arc::new(Auth::basic("alice", "secret")); + let handler = Arc::new(Handler::new(Arc::new(Context::new()))); + let server = RpcServer::bind( + "127.0.0.1:0", + auth, + handler, + 4, + core::time::Duration::from_millis(500), + false, + )?; + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = Arc::clone(&shutdown); + let handle = std::thread::spawn(move || server.serve_with_shutdown(shutdown_clone)); + std::thread::sleep(core::time::Duration::from_millis(150)); + shutdown.store(true, Ordering::Release); + handle.join().expect("join serve thread") +} + +#[test] +fn split_path_query_preserves_path_and_query() { + assert_eq!( + split_path_query("/rest/headers/hash.json?count=10"), + ("/rest/headers/hash.json", "count=10") + ); + assert_eq!( + split_path_query("/rest/chaininfo.json"), + ("/rest/chaininfo.json", "") + ); +} + +#[test] +fn wf_02_classify_splits_rest_esplora_and_json_rpc() { + assert_eq!( + classify("GET", "/rest/chaininfo.json"), + HttpRoute::Rest { + path: "/rest/chaininfo.json", + query: "" + } + ); + assert_eq!( + classify("GET", "/api/blocks/tip/height"), + HttpRoute::EsploraGet { + surface: HttpSurface::EsploraPublic, + path: "/blocks/tip/height", + query: "" + } + ); + assert_eq!( + classify("GET", "/esplora/internal/mempool/txs?max_txs=1"), + HttpRoute::EsploraGet { + surface: HttpSurface::EsploraBackend, + path: "/internal/mempool/txs", + query: "max_txs=1" + } + ); + assert_eq!( + classify("POST", "/api/tx"), + HttpRoute::EsploraPost { + surface: HttpSurface::EsploraPublic, + path: "/tx" + } + ); + assert_eq!( + classify("POST", "/esplora/internal/txs"), + HttpRoute::EsploraPost { + surface: HttpSurface::EsploraBackend, + path: "/internal/txs" + } + ); + assert_eq!( + classify("GET", "/blocks/tip/height"), + HttpRoute::NotFound, + "unprefixed GET is not Esplora" + ); + assert_eq!( + classify("HEAD", "/api/tx"), + HttpRoute::NotFound, + "HEAD /api/tx must not run POST /tx" + ); + assert_eq!( + classify("PUT", "/"), + HttpRoute::NotFound, + "non-POST methods are not JSON-RPC" + ); + assert_eq!( + classify("GET", "/api/v1/block-height/0"), + HttpRoute::EsploraGet { + surface: HttpSurface::EsploraPublic, + path: "/v1/block-height/0", + query: "" + }, + "GET /api/v1 stays in the closed /api directory (404 inside Esplora)" + ); + assert_eq!( + classify("POST", "/api/v1/tx"), + HttpRoute::EsploraPost { + surface: HttpSurface::EsploraPublic, + path: "/v1/tx" + } + ); + assert_eq!( + classify("OPTIONS", "/api/blocks/tip/height"), + HttpRoute::Preflight { + surface: HttpSurface::EsploraPublic + } + ); + assert_eq!( + classify("OPTIONS", "/esplora/internal/txs"), + HttpRoute::NotFound + ); + assert_eq!( + classify("OPTIONS", "/rest/chaininfo.json"), + HttpRoute::NotFound + ); + assert_eq!(classify("OPTIONS", "/"), HttpRoute::NotFound); + assert_eq!(classify("POST", "/"), HttpRoute::JsonRpc); + assert_eq!(classify("POST", "/tx"), HttpRoute::JsonRpc); + assert_eq!(classify("GET", "/blocks/tip/height"), HttpRoute::NotFound); +} + +/// WF-02: a body-carrying POST without Content-Length is a framing +/// error, not an empty JSON-RPC request. +#[test] +fn post_without_content_length_is_a_framing_error() -> std::io::Result<()> { + let auth = Arc::new(Auth::basic("alice", "secret")); + let handler = Arc::new(Handler::new(Arc::new(Context::new()))); + let server = RpcServer::bind( + "127.0.0.1:0", + auth, + handler, + 4, + core::time::Duration::from_millis(500), + false, + )?; + let addr = server.listener.local_addr()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = Arc::clone(&shutdown); + let handle = std::thread::spawn(move || server.serve_with_shutdown(shutdown_clone)); + + let mut stream = TcpStream::connect(addr)?; + write!( + stream, + "POST / HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n" + )?; + stream.flush()?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf)?; + let text = String::from_utf8_lossy(&buf); + let code: u16 = text + .split_whitespace() + .nth(1) + .and_then(|token| token.parse().ok()) + .expect("HTTP status"); + // The connection-level framing error surfaces as a bad-request-class + // response before any JSON-RPC authentication or parsing runs. + assert!( + (400..500).contains(&code) && code != 401, + "expected a framing error status, got {code}" + ); + + shutdown.store(true, Ordering::SeqCst); + drop(handle.join().expect("server thread")); + Ok(()) +} + +/// Runs `read_request` over a loopback socket fed `request` in writes of +/// at most `chunk` bytes, after which the client closes its write half. +fn read_fed_request( + request: Vec, + chunk: usize, +) -> io::Result, RequestReadError>> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + let (server, _) = listener.accept()?; + let writer = thread::spawn(move || -> io::Result<()> { + for piece in request.chunks(chunk) { + client.write_all(piece)?; + } + client.shutdown(Shutdown::Write) + }); + let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(3))); + let result = read_whole_request(&mut reader, Duration::from_secs(3), Duration::from_secs(3)); + // Close the server end first: after a failed read, a writer blocked on + // a full socket buffer would otherwise never return. The writer's + // resulting error must not hide the read's. + drop(reader); + let written = writer.join().expect("writer thread"); + if result.is_ok() { + written?; + } + Ok(result) +} + +#[test] +fn body_arriving_across_reads_is_received_whole() -> io::Result<()> { + let body = b"0123456789abcdef".repeat(4_096); + let mut request = format!( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + request.extend_from_slice(&body); + let Ok(Some(received)) = read_fed_request(request, 1_000)? else { + panic!("a complete request must decode"); + }; + assert_eq!(received.body, body); + Ok(()) +} + +/// A body declared at the size cap but cut short is a framing error, as +/// before. +#[test] +fn declared_but_unsent_body_is_a_framing_error() -> io::Result<()> { + let request = format!( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: {MAX_BODY_BYTES}\r\n\r\npartial" + ); + match read_fed_request(request.into_bytes(), usize::MAX)? { + Err(RequestReadError::Io(error)) => { + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); + } + Err(RequestReadError::RestBodyTooLarge) => panic!("unexpected size rejection"), + Ok(_) => panic!("a truncated body was accepted"), + } + Ok(()) +} + +/// The body buffer tracks the bytes that arrived, not the declared length. +#[test] +fn declared_but_unsent_body_allocates_only_received_bytes() { + let mut body = Vec::new(); + let result = read_body( + &mut std::io::Cursor::new(b"partial"), + MAX_BODY_BYTES, + &mut body, + ); + assert!( + matches!(&result, Err(error) if error.kind() == io::ErrorKind::UnexpectedEof), + "{result:?}" + ); + assert_eq!(body, b"partial"); + assert!( + body.capacity() < 64 * 1_024, + "a {MAX_BODY_BYTES}-byte declaration reserved {} bytes", + body.capacity() + ); +} + +/// A body received in full keeps a buffer no larger than its declared +/// length, including at the size cap and at lengths that are not powers +/// of two. +#[test] +fn full_body_capacity_stays_within_the_declared_length() -> io::Result<()> { + for content_length in [MAX_BODY_BYTES, 9_000_000] { + let mut request = format!( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: {content_length}\r\n\r\n" + ) + .into_bytes(); + request.resize(request.len() + content_length, b'x'); + let Ok(Some(received)) = read_fed_request(request, 1_460)? else { + panic!("a complete request must decode"); + }; + assert_eq!(received.body.len(), content_length); + assert!( + received.body.capacity() <= content_length, + "a {content_length}-byte body kept {} bytes", + received.body.capacity() + ); + } + Ok(()) +} + +/// The request-head budget must be enforced while bytes arrive, even if +/// the peer never sends a newline or closes its write half. +#[test] +fn oversized_unterminated_head_is_rejected_before_newline() -> std::io::Result<()> { + for prefix in [ + b"GET /".as_slice(), + b"GET / HTTP/1.1\r\nX-Fill: ".as_slice(), + ] { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let auth = Arc::new(Auth::basic("alice", "secret")); + let handler = Arc::new(Handler::new(Arc::new(Context::new()))); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept request"); + serve_connection( + stream, + &auth, + &handler, + false, + core::time::Duration::from_secs(5), + RpcSurface::Shared, + ) + }); + + let mut client = TcpStream::connect(address)?; + client.set_read_timeout(Some(core::time::Duration::from_secs(2)))?; + client.write_all(prefix)?; + client.write_all(&vec![b'a'; MAX_HEADER_BYTES])?; + + let mut response = [0_u8; 128]; + let first_read = client.read(&mut response); + client.shutdown(Shutdown::Write)?; + let _ = server.join().expect("server thread"); + let read = first_read?; + assert!( + response[..read].starts_with(b"HTTP/1.1 400 Bad Request"), + "oversized head must fail before newline: {prefix:?}" + ); + } + Ok(()) +} + +#[test] +fn exact_request_head_budget_is_accepted() -> std::io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let auth = Arc::new(Auth::basic("alice", "secret")); + let handler = Arc::new(Handler::new(Arc::new(Context::new()))); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept request"); + serve_connection( + stream, + &auth, + &handler, + false, + core::time::Duration::from_secs(5), + RpcSurface::Shared, + ) + }); + + let mut request = b"GET / HTTP/1.1\r\nX-Fill: ".to_vec(); + request.extend(vec![ + b'a'; + MAX_HEADER_BYTES - request.len() - b"\r\n\r\n".len() + ]); + request.extend_from_slice(b"\r\n\r\n"); + assert_eq!(request.len(), MAX_HEADER_BYTES); + let mut client = TcpStream::connect(address)?; + client.write_all(&request)?; + let mut response = String::new(); + client.read_to_string(&mut response)?; + assert!(response.starts_with("HTTP/1.1 404 Not Found")); + server.join().expect("server thread")?; + Ok(()) +} + +#[test] +fn listener_directory_table_is_closed_over_http() -> std::io::Result<()> { + let auth = Arc::new(Auth::basic("alice", "secret")); + let handler = Arc::new(Handler::new(Arc::new(Context::new()))); + let server = RpcServer::bind( + "127.0.0.1:0", + auth, + handler, + 4, + core::time::Duration::from_millis(500), + false, + )?; + let addr = server.listener.local_addr()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_clone = Arc::clone(&shutdown); + let handle = std::thread::spawn(move || server.serve_with_shutdown(shutdown_clone)); + + let status = |method: &str, path: &str| -> std::io::Result { + let mut stream = TcpStream::connect(addr)?; + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + )?; + stream.flush()?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf)?; + let text = String::from_utf8_lossy(&buf); + let code = text + .split_whitespace() + .nth(1) + .and_then(|token| token.parse().ok()) + .expect("HTTP status"); + Ok(code) + }; + + assert_eq!(status("GET", "/blocks/tip/height")?, 404); + assert_eq!(status("HEAD", "/api/tx")?, 404); + assert_eq!(status("PUT", "/")?, 404); + assert_eq!(status("POST", "/")?, 401); + assert_eq!(status("GET", "/api/blocks/tip/height")?, 200); + + shutdown.store(true, Ordering::Release); + handle.join().expect("join serve thread")?; + Ok(()) +} + +#[test] +fn unsupported_method_is_404_at_listener() -> std::io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let auth = Arc::new(Auth::basic("alice", "secret")); + let handler = Arc::new(Handler::new(Arc::new(Context::new()))); + let thread = std::thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept request"); + serve_connection( + stream, + &auth, + &handler, + false, + core::time::Duration::from_secs(1), + RpcSurface::Shared, + ) + }); + + let mut client = TcpStream::connect(address)?; + client.write_all(b"HEAD /api/tx HTTP/1.1\r\nHost: localhost\r\n\r\n")?; + let mut response = String::new(); + client.read_to_string(&mut response)?; + assert!(response.starts_with("HTTP/1.1 404 Not Found")); + thread + .join() + .expect("listener thread") + .expect("serve request"); + Ok(()) +} + +// CONTRACT: docs/contracts/external-api.md#API-02 — the envelope rules +// follow the pinned Core 31.1 parser (`src/rpc/request.cpp:206-260`) and +// its legacy HTTP mapping (`src/httprpc.cpp:41-56`). +#[test] +fn non_string_jsonrpc_version_is_an_invalid_request() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":2,"id":1,"method":"getblockcount","params":[]}"#, + ); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!( + response.status, 400, + "legacy -32600 maps to HTTP 400 (Core httprpc.cpp:47-49)" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600) + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str), + Some("jsonrpc field must be a string"), + "Core's exact message for a non-string version: {body:?}" + ); + assert!( + body.get("result").is_none_or(Value::is_null), + "a rejected envelope must not carry a result: {body:?}" + ); +} + +#[test] +fn unsupported_jsonrpc_version_is_an_invalid_request() { + let handler = Handler::new(Arc::new(Context::new())); + for version in ["9.0", "1.1"] { + let request = + format!(r#"{{"jsonrpc":"{version}","id":1,"method":"getblockcount","params":[]}}"#); + let response = handle_json(&handler, request.as_bytes()); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!( + response.status, 400, + "legacy -32600 maps to HTTP 400 (Core httprpc.cpp:47-49)" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600), + "version {version} must be refused" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str), + Some("JSON-RPC version not supported"), + "Core's exact message for an unsupported version: {body:?}" + ); + } +} + +#[test] +fn json_rpc_1_0_remains_accepted_as_legacy() { + // Core keeps the historical `{"jsonrpc":"1.0"}` form as legacy + // (`request.cpp:216-224`); it must not become an invalid request. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"1.0","id":1,"method":"getblockchaininfo","params":[]}"#, + ); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!(response.status, 200); + assert!(body.get("result").is_some()); + assert!(body.get("jsonrpc").is_none()); +} + +#[test] +fn non_array_or_object_params_are_an_invalid_request() { + let handler = Handler::new(Arc::new(Context::new())); + for params in ["7", r#""text""#, "true"] { + let request = + format!(r#"{{"jsonrpc":"2.0","id":1,"method":"getblockcount","params":{params}}}"#); + let response = handle_json(&handler, request.as_bytes()); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600), + "params {params} must be refused before dispatch" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str), + Some("Params must be an array or object"), + "Core's exact message for a malformed params field: {body:?}" + ); + } +} + +#[test] +fn a_rejected_envelope_never_dispatches_the_method() { + // Dispatch probe: `setnetworkactive` mutates a Context-owned flag, + // so execution is observable outside the reply. A malformed envelope + // must produce -32600 and leave the flag untouched — a discarded + // dispatch would still show up here even if the -32600 replaced it. + let handler = Handler::new(Arc::new(Context::new())); + let executed = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":1,"method":"setnetworkactive","params":[false]}"#, + ); + assert_eq!( + executed.status, 200, + "fixture: the probe method is dispatched" + ); + assert!( + !handler.context().network.p2p.network_active(), + "fixture: dispatch flips the flag" + ); + handler.context().network.p2p.set_network_active(true); + + for request in [ + br#"{"jsonrpc":"9.0","id":1,"method":"setnetworkactive","params":[false]}"#.as_slice(), + br#"{"jsonrpc":2,"id":1,"method":"setnetworkactive","params":[false]}"#.as_slice(), + br#"{"jsonrpc":"2.0","id":1,"method":"setnetworkactive","params":7}"#.as_slice(), + br#"{"jsonrpc":"2.0","id":1,"params":[false]}"#.as_slice(), + ] { + let rejected = handle_json(&handler, request); + let body = rejected.body.expect("rejected envelope replies"); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600), + "the envelope error must replace the method's own outcome: {body:?}" + ); + assert!( + handler.context().network.p2p.network_active(), + "a rejected envelope must not execute the method: {request:?}" + ); + } +} + +#[test] +fn an_empty_batch_answers_an_empty_ok_batch() { + // Core's batch reply path produces an empty array for an empty + // batch (`httprpc.cpp`): HTTP 200 with `[]`. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json(&handler, b"[]"); + let body = response.body.expect("empty batch replies"); + + assert_eq!(response.status, 200); + assert_eq!(body, json!([]), "an empty batch answers []: {body:?}"); +} + +#[test] +fn a_top_level_scalar_is_a_parse_error() { + // Core's final `else` in `HTTPReq_JSONRPC` answers `RPC_PARSE_ERROR` + // (-32700) "Top-level object parse error" at HTTP 500 with a null id. + let handler = Handler::new(Arc::new(Context::new())); + for request in [br"5".as_slice(), br#""x""#.as_slice()] { + let response = handle_json(&handler, request); + let body = response.body.expect("top-level scalar replies"); + + assert_eq!(response.status, 500, "-32700 keeps the legacy 500"); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32700), + "top-level scalar is a parse error: {body:?}" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str), + Some("Top-level object parse error"), + "Core's exact message: {body:?}" + ); + assert!( + body.get("id").is_none_or(Value::is_null), + "a parse error carries a null id: {body:?}" + ); + } +} + +#[test] +fn json_rpc_2_method_and_params_envelope_errors_answer_400() { + // parse() has already accepted `"2.0"` when method/params validation + // throws, so the body keeps the v2 envelope while the status is the + // 400 every envelope refusal takes (`httprpc.cpp`). `"method":null` + // is "Missing method": Core checks `isNull()` before `isStr()`. + let handler = Handler::new(Arc::new(Context::new())); + for (request, message) in [ + ( + r#"{"jsonrpc":"2.0","id":1,"method":"getblockcount","params":7}"#, + "Params must be an array or object", + ), + (r#"{"jsonrpc":"2.0","id":1,"params":[]}"#, "Missing method"), + ( + r#"{"jsonrpc":"2.0","id":1,"method":null}"#, + "Missing method", + ), + ( + r#"{"jsonrpc":"2.0","id":1,"method":5}"#, + "Method must be a string", + ), + ] { + let response = handle_json(&handler, request.as_bytes()); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!( + response.status, 400, + "v2 envelope refusal maps to HTTP 400: {request}" + ); + assert_eq!( + body.get("jsonrpc").and_then(Value::as_str), + Some("2.0"), + "the body keeps the v2 envelope: {body:?}" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600), + "envelope refusal is -32600: {body:?}" + ); + assert_eq!( + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str), + Some(message), + "Core's exact message for {request}: {body:?}" + ); + } +} + +#[test] +fn an_all_notification_batch_answers_204() { + // A non-empty batch whose members are all valid v2 notifications has + // no reply rows, so Core answers HTTP 204 (`httprpc.cpp:219-227`). + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"[ + {"jsonrpc":"2.0","method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","method":"getblockchaininfo","params":[]} + ]"#, + ); + + assert_eq!(response.status, 204); + assert!(response.body.is_none(), "204 carries no body"); +} + +#[test] +fn legacy_invalid_request_maps_to_http_400() { + // A legacy request whose method is not a string is a -32600, which + // Core answers with HTTP 400 (`httprpc.cpp:47-53`). + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json(&handler, br#"{"id":1,"method":7,"params":[]}"#); + assert_eq!(response.status, 400, "legacy -32600 maps to HTTP 400"); +} + +#[test] +fn legacy_method_not_found_maps_to_http_404() { + // Core maps RPC_METHOD_NOT_FOUND to HTTP 404 for legacy requests + // (`httprpc.cpp:51-53`); this previously answered 500. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json(&handler, br#"{"id":1,"method":"missing","params":[]}"#); + assert_eq!(response.status, 404, "legacy -32601 maps to HTTP 404"); +} + +#[test] +fn legacy_other_errors_keep_http_500() { + // Everything except -32600 and -32601 stays 500 for legacy requests. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json(&handler, br#"{"id":1,"method":"getblock","params":[]}"#); + assert_eq!(response.status, 500, "legacy -32602 keeps HTTP 500"); +} + +#[test] +fn json_rpc_2_dispatch_errors_still_answer_http_200() { + // Core keeps *dispatch* errors inside HTTP 200 for a v2 request + // (`httprpc.cpp:118-124`); only envelope failures, which happen + // before the version is trusted, take the legacy HTTP status. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":7,"method":"missing","params":[]}"#, + ); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!(response.status, 200, "v2 -32601 stays inside HTTP 200"); + assert_eq!(body.get("jsonrpc").and_then(Value::as_str), Some("2.0")); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32601) + ); +} + +#[test] +fn json_rpc_2_envelope_error_uses_the_legacy_status() { + // A malformed envelope is refused while classifying `jsonrpc`, before + // the claimed version is trusted, so it takes the legacy HTTP mapping + // and envelope shape (`request.cpp:206-224`, `httprpc.cpp:41-56`). + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"9.0","id":1,"method":"getblockcount","params":[]}"#, + ); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!(response.status, 400); + assert!(body.get("jsonrpc").is_none(), "legacy envelope: {body:?}"); +} + +#[test] +fn json_rpc_2_post_classification_error_keeps_the_v2_envelope() { + // Core's parser assigns the version before checking method/params + // (`request.cpp:211-231`), so a v2 request rejected later still + // replies in the v2 envelope — but the outer error path answers + // HTTP 400, not the dialect's 200 (`httprpc.cpp:189-194`). + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":1,"method":"getblockcount","params":7}"#, + ); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!(response.status, 400); + assert_eq!( + body.get("jsonrpc").and_then(Value::as_str), + Some("2.0"), + "a post-classification violation keeps the v2 envelope: {body:?}" + ); + assert!(body.get("result").is_none(), "v2 errors omit result"); + assert_eq!( + body.get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600) + ); +} + +#[test] +fn an_explicit_null_method_is_missing() { + // Core checks `valMethod.isNull()` before its string check + // (`request.cpp:226-229`), so `"method":null` is "Missing method", + // not "Method must be a string". + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":1,"method":null,"params":[]}"#, + ); + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!( + body.get("error") + .and_then(|error| error.get("message")) + .and_then(Value::as_str), + Some("Missing method"), + "Core's exact message for a null method: {body:?}" + ); +} + +#[test] +fn absent_and_null_params_dispatch_as_an_empty_array() { + // Core canonicalizes absent and explicit-null params to `[]` before + // dispatch (`request.cpp:243-248`), so no-argument methods apply + // their defaults instead of failing on the null. + let handler = Handler::new(Arc::new(Context::new())); + for request in [ + br#"{"jsonrpc":"2.0","id":1,"method":"getblockcount"}"#.as_slice(), + br#"{"jsonrpc":"2.0","id":1,"method":"getblockcount","params":null}"#.as_slice(), + ] { + let response = handle_json(&handler, request); + let body = response.body.expect("no-argument call replies"); + assert_eq!(response.status, 200, "{request:?}"); + assert!( + body.get("result").is_some(), + "a null params field must dispatch with defaults: {body:?}" + ); + } +} + +#[test] +fn batch_members_keep_per_member_envelope_dialect() { + // Batch members are parsed independently by Core + // (`httprpc.cpp:200-215`), so a v2 member and a legacy member in one + // batch keep their own envelopes even when a sibling is malformed. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"[ + {"jsonrpc":"2.0","id":1,"method":"getblockcount","params":[]}, + {"jsonrpc":"9.0","id":2,"method":"getblockcount","params":[]} + ]"#, + ); + let body = response.body.expect("JSON-RPC batch response body"); + let rows = body.as_array().expect("batch response array"); + + assert_eq!(response.status, 200); + assert_eq!(rows.len(), 2); + assert!(rows[0].get("result").is_some()); + assert_eq!( + rows[1] + .get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_i64), + Some(-32600) + ); +} + +#[test] +fn notification_with_a_malformed_envelope_is_not_executed() { + // A rejected envelope must not run, so a notification cannot silently + // cause work: the response is an error, not the 204 a notification + // would produce. + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","method":"getblockcount","params":7}"#, + ); + assert!( + response.body.is_some(), + "a malformed notification envelope must be reported, not executed" + ); +} + +#[test] +fn json_rpc_2_success_omits_null_error_for_jsonrpsee_clients() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}"#, + ); + let response = response.body.expect("JSON-RPC response body"); + + assert_eq!(response.get("jsonrpc").and_then(Value::as_str), Some("2.0")); + assert!(response.get("result").is_some()); + assert!(response.get("error").is_none()); +} + +#[test] +fn bitcoin_core_1_success_keeps_null_error() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"1.0","id":1,"method":"getblockchaininfo","params":[]}"#, + ); + let response = response.body.expect("JSON-RPC response body"); + + assert!(response.get("jsonrpc").is_none()); + assert!(response.get("result").is_some()); + assert!(response.get("error").is_some_and(Value::is_null)); +} + +#[test] +fn json_rpc_2_error_omits_result_and_uses_http_200() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","id":7,"method":"missing","params":[]}"#, + ); + let status = response.status; + let body = response.body.expect("JSON-RPC response body"); + + assert_eq!(status, 200); + assert!(body.get("result").is_none()); + assert!(body.get("error").is_some_and(Value::is_object)); + assert_eq!(body.get("id").and_then(Value::as_i64), Some(7)); +} + +#[test] +fn json_rpc_2_notification_has_no_response_body() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"{"jsonrpc":"2.0","method":"getblockcount","params":[]}"#, + ); + + assert!(response.body.is_none()); +} + +#[test] +fn json_rpc_batch_excludes_notifications() { + let handler = Handler::new(Arc::new(Context::new())); + let response = handle_json( + &handler, + br#"[ + {"jsonrpc":"2.0","id":1,"method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","method":"getblockcount","params":[]}, + {"jsonrpc":"2.0","id":2,"method":"missing","params":[]} + ]"#, + ); + let status = response.status; + let body = response.body.expect("JSON-RPC batch response body"); + let rows = body.as_array().expect("batch response array"); + + assert_eq!(status, 200); + assert_eq!(rows.len(), 2); + assert!(rows[0].get("result").is_some()); + assert!(rows[1].get("error").is_some()); +} + +/// Reads one request the way `serve_connection` does, minus the +/// authentication gate between the two phases. +fn read_whole_request( + reader: &mut BufReader, + head_timeout: Duration, + body_timeout: Duration, +) -> Result, RequestReadError> { + match read_request_head(reader, head_timeout)? { + Some(head) => read_request_body(reader, body_timeout, head).map(Some), + None => Ok(None), + } +} + +/// What one loopback client saw while `serve_connection` handled its request. +struct Exchange { + /// Bytes received until the server's FIN or a read failure. + response: String, + /// Whether the server closed its write side. A read timeout means it + /// neither answered nor closed. + eof: bool, + /// Time from the first write until the read ended. + elapsed: Duration, + /// The connection handler's own verdict, taken after the client hangs up. + served: io::Result<()>, +} + +/// Serves one loopback connection with `serve_connection`, writes `request` +/// and nothing more, and reports what the client saw. The client keeps its +/// side open until the response ends, as a client still uploading would. +fn exchange(request: &[u8]) -> io::Result { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + client.set_read_timeout(Some(Duration::from_secs(5)))?; + let server = thread::spawn(move || -> io::Result<()> { + let (stream, _) = listener.accept()?; + serve_connection( + stream, + &Auth::basic("alice", "secret"), + &Handler::new(Arc::new(Context::new())), + false, + Duration::from_secs(30), + RpcSurface::Shared, + ) + }); + let started = Instant::now(); + client.write_all(request)?; + let mut response = Vec::new(); + let mut eof = false; + let mut chunk = [0_u8; 1_024]; + loop { + match client.read(&mut chunk) { + Ok(0) => { + eof = true; + break; + } + Ok(read) => response.extend_from_slice(&chunk[..read]), + Err(_) => break, + } + } + let elapsed = started.elapsed(); + drop(client); + let served = server.join().expect("server thread"); + Ok(Exchange { + response: String::from_utf8_lossy(&response).into_owned(), + eof, + elapsed, + served, + }) +} + +/// `Authorization` tokens for `alice:secret` and `alice:bad`. +const GOOD_TOKEN: &str = "YWxpY2U6c2VjcmV0"; +const BAD_TOKEN: &str = "YWxpY2U6YmFk"; + +/// Core's 401 for JSON-RPC: the challenge, its default error type, an empty +/// body, a close, and no `Date`. +const CORE_UNAUTHORIZED: &str = "HTTP/1.1 401 Unauthorized\r\n\ + WWW-Authenticate: Basic realm=\"jsonrpc\"\r\n\ + Content-Type: text/html; charset=ISO-8859-1\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + +fn json_rpc_head(token: Option<&str>, content_length: usize) -> Vec { + let authorization = token.map_or_else(String::new, |token| { + format!("Authorization: Basic {token}\r\n") + }); + format!( + "POST / HTTP/1.1\r\nHost: localhost\r\n{authorization}Content-Length: {content_length}\r\n\r\n" + ) + .into_bytes() +} + +/// A JSON-RPC request without valid credentials is answered from its head: +/// the client withholds all 16 MiB it declared and still gets Core's exact +/// 401 and a FIN. A wrong password waits out Core's brute-force delay first. +#[test] +fn unauthorized_json_rpc_is_answered_from_the_head_with_core_s_tuple() -> io::Result<()> { + for (token, delayed) in [(None, false), (Some(BAD_TOKEN), true)] { + let seen = exchange(&json_rpc_head(token, MAX_BODY_BYTES))?; + assert_eq!(seen.response, CORE_UNAUTHORIZED, "token {token:?}"); + assert!(seen.eof, "the server must close after the 401: {token:?}"); + assert!(seen.served.is_ok(), "a 401 is a served request: {token:?}"); + if delayed { + assert!( + seen.elapsed >= WRONG_CREDENTIALS_DELAY, + "wrong credentials answered in {:?}", + seen.elapsed + ); + } + } + Ok(()) +} + +/// Bytes pipelined behind an unauthorized request are discarded, never +/// served: the one response is the 401. +#[test] +fn unauthorized_request_never_serves_pipelined_bytes() -> io::Result<()> { + let mut request = json_rpc_head(None, 2); + request.extend_from_slice(b"{}GET /rest/chaininfo.json HTTP/1.1\r\nHost: localhost\r\n\r\n"); + let seen = exchange(&request)?; + assert_eq!(seen.response, CORE_UNAUTHORIZED); + assert!(seen.eof); + Ok(()) +} + +/// A 401 witnesses `authenticate_json_rpc_head` on a real loopback pair: +/// the one declared byte is prebuffered before the call, the client stays +/// open through the drain, and the call consumes the buffer without the +/// one-second budget hold before the client sees the 401 and EOF. +#[test] +fn unauthorized_request_with_buffered_body_returns_without_the_drain_hold() -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + client.set_read_timeout(Some(Duration::from_secs(5)))?; + let (server, _) = listener.accept()?; + let mut request = json_rpc_head(None, 1); + request.extend_from_slice(b"X"); + client.write_all(&request)?; + let mut reader = BufReader::new(RequestDeadlineStream::new(server, Duration::from_secs(30))); + let head = match read_request_head(&mut reader, REQUEST_HEAD_TIMEOUT) { + Ok(Some(head)) => head, + Ok(None) => return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "missing head")), + Err(RequestReadError::Io(error)) => return Err(error), + Err(RequestReadError::RestBodyTooLarge) => { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body too large")); + } + }; + assert_eq!(head.content_length, 1); + reader.get_mut().reset_deadline(REQUEST_HEAD_TIMEOUT); + assert_eq!( + reader.fill_buf()?, + b"X".as_slice(), + "declared byte must be prebuffered before the 401" + ); + let started = Instant::now(); + let handled = authenticate_json_rpc_head(&Auth::basic("alice", "secret"), &mut reader, &head)?; + let elapsed = started.elapsed(); + assert!(handled, "unauthorized head must be handled"); + assert!( + reader.buffer().is_empty(), + "drain must consume the prebuffered byte: {:?}", + reader.buffer() + ); + assert!( + elapsed < DRAIN_BUDGET, + "a buffered body must not wait out the drain budget: {elapsed:?}" + ); + let mut response = String::new(); + client.read_to_string(&mut response)?; + assert_eq!(response, CORE_UNAUTHORIZED); + Ok(()) +} + +/// Runs the unauthorized path on the server side of one loopback pair and +/// returns the client's view of the response plus the next `take` bytes +/// the server reads once the handler returns. The server thread starts +/// first and reads while the client writes, so neither side waits on a +/// buffer the other has not drained. +fn serve_unauthorized(request: &[u8], take: usize) -> io::Result<(String, Vec)> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result> { + let (stream, _) = listener.accept()?; + let mut reader = + BufReader::new(RequestDeadlineStream::new(stream, Duration::from_secs(30))); + let head = match read_request_head(&mut reader, REQUEST_HEAD_TIMEOUT) { + Ok(Some(head)) => head, + Ok(None) => { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "missing head")); + } + Err(RequestReadError::Io(error)) => return Err(error), + Err(RequestReadError::RestBodyTooLarge) => { + return Err(io::Error::new(io::ErrorKind::InvalidData, "body too large")); + } + }; + let handled = + authenticate_json_rpc_head(&Auth::basic("alice", "secret"), &mut reader, &head)?; + assert!(handled, "unauthorized head must be handled"); + let mut next = vec![0_u8; take]; + reader.read_exact(&mut next)?; + Ok(next) + }); + let mut client = TcpStream::connect(addr)?; + client.set_read_timeout(Some(Duration::from_secs(5)))?; + client.write_all(request)?; + client.shutdown(Shutdown::Write)?; + let mut response = String::new(); + client.read_to_string(&mut response)?; + let next = server.join().expect("server thread")?; + Ok((response, next)) +} + +/// The drain consumes exactly the declared body, even at the cap, so the +/// bytes pipelined behind it are still on the stream for the next read. +#[test] +fn unauthorized_drain_consumes_exactly_the_declared_body() -> io::Result<()> { + let mut request = json_rpc_head(None, MAX_DRAIN_BYTES); + request.extend_from_slice(&vec![b'X'; MAX_DRAIN_BYTES]); + request.extend_from_slice(b"NEXT"); + let (response, next) = serve_unauthorized(&request, 4)?; + assert_eq!(response, CORE_UNAUTHORIZED); + assert_eq!(next, b"NEXT"); + Ok(()) +} + +/// A declared body above the cap is drained only up to the cap: the peer +/// still gets the 401 and a clean close, and the bytes past the cap stay +/// unread on the stream. +#[test] +fn unauthorized_drain_stops_at_the_cap_for_an_oversized_body() -> io::Result<()> { + const TAIL: usize = 4 * 1_024; + let mut request = json_rpc_head(None, MAX_DRAIN_BYTES + TAIL); + request.extend_from_slice(&vec![b'X'; MAX_DRAIN_BYTES]); + request.extend_from_slice(&vec![b'Y'; TAIL]); + let (response, rest) = serve_unauthorized(&request, TAIL)?; + assert_eq!(response, CORE_UNAUTHORIZED); + assert_eq!(rest, vec![b'Y'; TAIL]); + Ok(()) +} + +/// A head that fails HTTP framing is a 400 whether or not the client sent +/// credentials: authentication runs only on a well-framed head. +#[test] +fn head_framing_errors_keep_their_400_without_credentials() -> io::Result<()> { + let oversize = json_rpc_head(None, MAX_BODY_BYTES + 1); + for (name, request) in [ + ( + "chunked", + b"POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(), + ), + ( + "missing length", + b"POST / HTTP/1.1\r\nHost: localhost\r\n\r\n".to_vec(), + ), + ( + "conflicting length", + b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\nContent-Length: 3\r\n\r\n" + .to_vec(), + ), + ("oversize length", oversize), + ] { + let seen = exchange(&request)?; + assert!( + seen.response.starts_with("HTTP/1.1 400 Bad Request"), + "{name}: {}", + seen.response + ); + } + Ok(()) +} + +/// Valid credentials pass the gate and the body is then read: a body cut +/// short is the framing error it always was, not a 401 and not a hang. +#[test] +fn authorized_request_proceeds_to_body_framing() -> io::Result<()> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let mut client = TcpStream::connect(listener.local_addr()?)?; + client.set_read_timeout(Some(Duration::from_secs(5)))?; + let server = thread::spawn(move || -> io::Result<()> { + let (stream, _) = listener.accept()?; + serve_connection( + stream, + &Auth::basic("alice", "secret"), + &Handler::new(Arc::new(Context::new())), + false, + Duration::from_secs(30), + RpcSurface::Shared, + ) + }); + let mut request = json_rpc_head(Some(GOOD_TOKEN), 16); + request.extend_from_slice(b"{}"); + client.write_all(&request)?; + client.shutdown(Shutdown::Write)?; + let mut response = Vec::new(); + client.read_to_end(&mut response)?; + let _ignored = server.join(); + let text = String::from_utf8_lossy(&response); + assert!(text.starts_with("HTTP/1.1 400 Bad Request"), "{text}"); + Ok(()) +} + +fn accept_test_server() -> io::Result { + RpcServer::bind( + "127.0.0.1:0", + Arc::new(Auth::basic("alice", "secret")), + Arc::new(Handler::new(Arc::new(Context::new()))), + 4, + Duration::from_secs(1), + false, + ) +} + +// Inject at the syscall boundary, without exhausting the test process's +// descriptors. The listener and subsequent HTTP worker remain real. +#[test] +fn accept_recovers_from_resource_and_connection_errors() -> io::Result<()> { + let server = accept_test_server()?; + let addr = server.local_addr()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&shutdown); + let handle = thread::spawn(move || { + let mut errors = vec![ + io::Error::from(io::ErrorKind::Interrupted), + io::Error::from(io::ErrorKind::ConnectionAborted), + io::Error::from(io::ErrorKind::ConnectionReset), + io::Error::from(io::ErrorKind::PermissionDenied), + io::Error::from(io::ErrorKind::Unsupported), + io::Error::other("pending protocol error"), + ]; + #[cfg(unix)] + errors.extend([ + io::Error::from_raw_os_error(rustix::io::Errno::MFILE.raw_os_error()), + io::Error::from_raw_os_error(rustix::io::Errno::NFILE.raw_os_error()), + ]); + #[cfg(target_os = "linux")] + { + let error = io::Error::from_raw_os_error(rustix::io::Errno::OPNOTSUPP.raw_os_error()); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + errors.push(error); + } + #[cfg(windows)] + errors.push(io::Error::from_raw_os_error(10024)); // WSAEMFILE + let mut errors = errors.into_iter(); + let start = Instant::now(); + server.serve_accept_loop(&stop.into(), RpcSurface::Shared, |listener| { + if let Some(error) = errors.next() { + return Err(error); + } + assert!(start.elapsed() >= POLL_INTERVAL * 3); + listener.accept() + }) + }); + let response = (|| -> io::Result { + let mut client = TcpStream::connect(addr)?; + client.set_read_timeout(Some(Duration::from_secs(5)))?; + client.write_all( + b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + )?; + let mut response = String::new(); + client.read_to_string(&mut response)?; + Ok(response) + })(); + shutdown.store(true, Ordering::Release); + handle.join().expect("accept thread")?; + assert!(response?.starts_with("HTTP/1.1 401")); + Ok(()) +} + +#[test] +fn repeated_accept_errors_back_off_and_observe_shutdown() -> io::Result<()> { + let server = accept_test_server()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&shutdown); + let start = Instant::now(); + let mut attempts = 0; + server.serve_accept_loop(&shutdown.into(), RpcSurface::Shared, |_| { + attempts += 1; + if attempts == 3 { + stop.store(true, Ordering::Release); + } + Err(io::Error::other("resource temporarily exhausted")) + })?; + assert_eq!(attempts, 3); + assert!(start.elapsed() >= POLL_INTERVAL * 3); + Ok(()) +} + +#[test] +fn invalid_listener_errors_are_returned() -> io::Result<()> { + for kind in [io::ErrorKind::InvalidInput, io::ErrorKind::NotConnected] { + let server = accept_test_server()?; + let shutdown = Arc::new(AtomicBool::new(false)); + let mut attempts = 0; + let result = server.serve_accept_loop(&shutdown.into(), RpcSurface::Shared, |_| { + attempts += 1; + Err(kind.into()) + }); + assert_eq!(result.expect_err("fatal listener error").kind(), kind); + assert_eq!(attempts, 1); + } + Ok(()) +} diff --git a/crates/rpc/src/zmq.rs b/crates/rpc/src/zmq.rs index d2fe04293..6d911d10a 100644 --- a/crates/rpc/src/zmq.rs +++ b/crates/rpc/src/zmq.rs @@ -6,7 +6,7 @@ //! transported. #[cfg(feature = "zmq")] -use anyhow::{Context as _, bail}; +use anyhow::Context as _; use anyhow::{Result, ensure}; use bitcoin_rs_primitives::{Hash256, Txid}; @@ -15,8 +15,6 @@ use bitcoin_rs_primitives::{Amount, LockTime, Script, Sequence, Witness}; #[cfg(feature = "zmq")] use core::fmt; #[cfg(feature = "zmq")] -use hashbrown::HashSet; -#[cfg(feature = "zmq")] use parking_lot::Mutex; use serde::Deserialize; use std::collections::HashSet as StdHashSet; @@ -263,6 +261,22 @@ impl ZmqPublisher for NoOpZmqPublisher { fn publish_rawtx(&self, _bytes: &[u8]) {} } +/// Publish one `hashtx` notification per newly admitted transaction id. +/// +/// The sole caller is [`MempoolSequenceObserver`]'s `Accepted` arm, which +/// passes each committed `Accepted` change's txid in mutation order. +/// Rejected submissions, already-known (duplicate) admissions, and removals +/// never reach this helper: duplicates and refusals commit nothing, and +/// block-inclusion removals publish nothing. +pub fn publish_admitted_hashtx( + publisher: &dyn ZmqPublisher, + txids: impl IntoIterator, +) { + for txid in txids { + publisher.publish_hashtx(txid); + } +} + #[cfg(feature = "zmq")] struct EndpointSocket { endpoint: String, @@ -320,19 +334,14 @@ impl SocketZmqPublisher { validate_endpoint_configs(&deduped_configs)?; let context = zmq::Context::new(); let mut endpoints = Vec::new(); - let mut bound_endpoints = HashSet::::new(); let mut hashblock_endpoints = Vec::new(); let mut hashtx_endpoints = Vec::new(); let mut rawblock_endpoints = Vec::new(); let mut rawtx_endpoints = Vec::new(); let mut sequence_endpoints = Vec::new(); let mut notifiers = Vec::new(); - let mut seen_notifiers = HashSet::<(ZmqTopic, String)>::new(); for endpoint_config in &deduped_configs { - if !bound_endpoints.insert(endpoint_config.endpoint.clone()) { - bail!("duplicate ZMQ endpoint {}", endpoint_config.endpoint); - } let hwm = endpoint_config.effective_hwm(); let socket = context.socket(zmq::PUB).context("create ZMQ PUB socket")?; socket @@ -357,9 +366,6 @@ impl SocketZmqPublisher { socket: Mutex::new(socket), }); for &topic in &endpoint_config.topics { - if !seen_notifiers.insert((topic, endpoint_config.endpoint.clone())) { - continue; - } notifiers.push(ZmqNotifier { topic, endpoint: endpoint_config.endpoint.clone(), @@ -1077,7 +1083,7 @@ mod manifest_tests { let address = format!("ipc://{}", dir.path().join("admission.sock").display()); let publisher = Arc::new(SocketZmqPublisher::bind(&[ZmqEndpointConfig { endpoint: address.clone(), - topics: vec![ZmqTopic::RawTx, ZmqTopic::Sequence], + topics: vec![ZmqTopic::HashTx, ZmqTopic::RawTx, ZmqTopic::Sequence], hwm: Some(100), }])?); let context = zmq::Context::new(); @@ -1113,6 +1119,14 @@ mod manifest_tests { let tx = sequence_tx(91); let expected = consensus_bytes(&tx); gateway.insert_entry(AdmissionOrigin::Rpc, sequence_entry(&tx))?; + let announced = subscriber.recv_multipart(0)?; + assert_eq!(announced[0].as_slice(), b"hashtx"); + assert_eq!( + announced[1], + reversed_hash_body(*tx.txid().as_bytes()), + "hashtx carries the admitted txid in wire byte order" + ); + assert_eq!(announced[2], 0_u32.to_le_bytes()); let raw = subscriber.recv_multipart(0)?; assert_eq!(raw[0].as_slice(), b"rawtx"); assert_eq!(raw[1], expected); diff --git a/crates/rpc/tests/auth.rs b/crates/rpc/tests/auth.rs index 66e1b4e1d..c0487831a 100644 --- a/crates/rpc/tests/auth.rs +++ b/crates/rpc/tests/auth.rs @@ -68,7 +68,9 @@ fn legacy_error_has_core_envelope_and_http_error() -> Result<(), Box>, } -impl MempoolObserver for RecordingGatewayObserver { - fn on_mutation(&self, envelope: &MutationEnvelope) { + impl MempoolObserver for RecordingGatewayObserver { + fn on_mutation(&self, envelope: &MutationEnvelope) { let result = &envelope.result; let mut changes = self.changes.lock(); for (offset, change) in result.changes.iter().enumerate() { diff --git a/docs/bip-support.md b/docs/bip-support.md index 127caa86f..f112c64d9 100644 --- a/docs/bip-support.md +++ b/docs/bip-support.md @@ -72,6 +72,7 @@ Linked capability names lead to code or tests; notes link scope and evidence own | 389 | [Multipath descriptors](../crates/rpc/src/handlers/util.rs) | ✅ | — | Multipath key expressions in getdescriptorinfo/deriveaddresses | | 389 | scantxoutset public multipath scans ([#1676](https://github.com/gosuda/bitcoin-rs/pull/1676)) | ☐ | — | bounded public multipath scans ([API-35](contracts/external-api.md#api-35-bounded-public-descriptor-utxo-scans)) | | 352 | [Silent Payments](../crates/index/src/silent_payments.rs) | 🧪 | — | Opt-in keyless codec and authenticated `getblocktweaks` extension; external wallet interoperability unverified; [docs](silent-payments.md) | +| 337 | [Compressed transactions](../crates/rpc/src/compressed_tx.rs) | 🧪 | — | Default-off `--compressed-transactions` extension (`compressrawtransaction`/`decompressrawtransaction`, `-32603` while disabled); no consensus, relay, or mempool surface. Reference readings recorded in the codec docs: carry-aware Core VARINT over the 7-bit prose (`a608` = 5000), the six-bit input metadata carries the public-key-hash bit the prose table omits, compressed signatures cover only P2TR key-path/P2PKH/P2WPKH/P2SH-P2WPKH with the uncompressed VARINT scriptSig/witness envelope for everything else, minimum height follows the reference `locktime - 1` / `coin.height - 1` rule with the `u32::MAX` sentinel encoded as zero, and a compressed outpoint is emitted only for a prevout with a confirmed height and a resolvable flat output index, otherwise the raw outpoint is emitted with one `UTXO(i): ` warning and no outpoint is ever fabricated | ## Out of scope by policy diff --git a/docs/contracts/embedding.md b/docs/contracts/embedding.md index ab0917d37..8619c202a 100644 --- a/docs/contracts/embedding.md +++ b/docs/contracts/embedding.md @@ -173,8 +173,11 @@ worker without `test-seam` or an internal RPC client. height and big-endian cumulative work from the block-tree owner without requiring a retained body. `Node::broadcast_package` routes bounded native child-with-independent-parents CPFP through -`MempoolGateway::submit_local_package` with the ordinary fee cap; it does not -enable RPC `submitpackage` or implement package RBF. Settled polling, opaque +`MempoolGateway::submit_local_package` with the ordinary fee cap. RPC +`submitpackage` uses the same atomic owner with its scoped individual-first +contract in [POL-02](mempool-policy.md#pol-02-one-admission-owner-and-admission-mode) +and [POL-05](mempool-policy.md#pol-05-replacement-cluster-and-package-policy); +neither interface implements package RBF. Settled polling, opaque replay targets, positional steps, gap/restart recovery and work bounds are owned by `EVT-06` in [chain-events.md](chain-events.md). `Node::snapshot()` retains its live applied-tip semantics; callers needing follower settlement use diff --git a/docs/contracts/external-api.md b/docs/contracts/external-api.md index 2bdbeaa93..e7c9f1ff2 100644 --- a/docs/contracts/external-api.md +++ b/docs/contracts/external-api.md @@ -48,21 +48,31 @@ scanning and wallet interoperability remain unverified. - Parameter type and coercion rules, named and positional forms, the declared JSON-RPC 1 and 2 envelopes, notifications, authentication, and error ordering follow the pinned Core 31.1 - contract. Batches follow it except for the two deviations noted - below (empty-array rejection, 204 for notification-only batches). -- The HTTP endpoint accepts JSON-RPC batch arrays as implemented by - `handle_decoded_json` in `crates/rpc/src/server.rs`: a top-level JSON - array dispatches each entry independently via `handle_single_json`, - preserving each entry's own 1.1/2.0 framing - (`JsonRpcVersion::from_request`). An empty array is rejected - (`InvalidRequest`, "batch must not be empty") with HTTP 500 and a - legacy-envelope error body (not an array) — Core 31.1 instead - answers `[]` with HTTP 200. Replies assemble into a - JSON array in request order; 2.0 notifications (requests without `id`) - contribute no entry. A notification-only batch answers HTTP 204 with no - body; otherwise the status is the maximum per-entry status. There is no - per-entry count limit; the bound is the HTTP body - `Content-Length <= MAX_BODY_BYTES` (16 MiB). + contract. +- The envelope is validated before any method dispatch + (`crates/rpc/src/server.rs::handle_single_json`), mirroring Core's + `JSONRPCRequest::parse` (`src/rpc/request.cpp:206-260`): a **non-null** + `jsonrpc` field must be a string and must be `"1.0"` (accepted as legacy + for compatibility) or `"2.0"`; a present `params` field must be an array, + an object, or `null`; and `method` must be present and a string. Core + checks `isNull()` before `isStr()`, so an explicit `null` method is + "Missing method", not "Method must be a string". An absent or + explicit-null `jsonrpc` is the legacy dialect, as in Core. A violation + is `-32600` carrying Core's exact message, and the method never runs. + A top-level JSON value that is neither an object nor an array is + `-32700` "Top-level object parse error" (`src/httprpc.cpp`, the final + `else` of `HTTPReq_JSONRPC`). +- HTTP status follows Core's mapping (`src/httprpc.cpp:41-56`). An + envelope refusal — an error raised while `parse()` runs — is `-32600` + at HTTP 400 whatever dialect the request claimed. Only the reply's + envelope depends on how far parsing got: a rejected `jsonrpc` field + falls back to the legacy envelope, while a `method`/`params` refusal + after a clean `"2.0"` keeps the v2 envelope. Dispatch errors then + follow the dialect: a legacy request answers with an HTTP error status + (`-32600` → 400, `-32601` → 404, every other code → 500), and a v2 + request stays inside HTTP 200. A batch that produces replies answers HTTP 200 + with per-member errors carried in its array; an empty batch answers HTTP 200 + `[]`; an all-notification batch answers 204 with no body. - Failures map through `RpcError` (`crates/rpc/src/error.rs`): standard JSON-RPC codes (`-32700`, `-32600`..=`-32603`) and Core codes `-1` (miscellaneous runtime failure), `-3` (invalid type), `-5` (not found), `-8` (invalid parameter), `-9` @@ -371,6 +381,29 @@ scanning and wallet interoperability remain unverified. counts retain the local JSON-RPC parameter-shape error instead of Core's method-help message. +Proof: `crates/rpc/src/server.rs` tests +`non_string_jsonrpc_version_is_an_invalid_request`, +`unsupported_jsonrpc_version_is_an_invalid_request`, +`json_rpc_1_0_remains_accepted_as_legacy`, +`non_array_or_object_params_are_an_invalid_request`, +`legacy_invalid_request_maps_to_http_400`, +`legacy_method_not_found_maps_to_http_404`, +`legacy_other_errors_keep_http_500`, +`json_rpc_2_dispatch_errors_still_answer_http_200`, +`json_rpc_2_envelope_error_uses_the_legacy_status`, +`json_rpc_2_method_and_params_envelope_errors_answer_400`, +`a_top_level_scalar_is_a_parse_error`, +`batch_members_keep_per_member_envelope_dialect`, +`notification_with_a_malformed_envelope_is_not_executed`, and +`a_rejected_envelope_never_dispatches_the_method`, and +`an_empty_batch_answers_an_empty_ok_batch`, and +`an_all_notification_batch_answers_204` pin the envelope rules, the Core +status mapping, execution prevention, and the batch reply shape; `crates/rpc/tests/auth.rs` +`legacy_error_has_core_envelope_and_http_error` pins them through a real +listener. The pinned Core 31.1 sources are `src/rpc/request.cpp:206-260` and +`src/httprpc.cpp:41-56` (no Core binary was run; the rules are read from the +pinned source). + ### `API-03`: REST dialect @@ -554,11 +587,14 @@ scanning and wallet interoperability remain unverified. ### `API-10`: Broadcast and preview through the admission gateway -- `sendrawtransaction`, `testmempoolaccept`, Esplora `POST /tx`, and P2P +- `sendrawtransaction`, `submitpackage`, `testmempoolaccept`, Esplora `POST /tx`, and P2P ingress reach the single `MempoolGateway` (`mempool-policy.md` `POL-02`). Each committing producer carries an explicit `AdmissionOrigin` and its - own request fee limits. Aggregate package submission remains unsupported - (`POL-05`); its future producer must use this same admission owner. + own request fee limits. Scoped `submitpackage` admits singletons and 1p1c + through the existing atomic package owner, with Core's individual-first + partial results (`POL-05`). Wider shapes and replacement are explicitly + refused. Named or positional `package`, `maxfeerate`, and `maxburnamount` + parameters feed the same owner; burn guards run before any mutation. - Esplora `POST /tx` is implemented on both `/api` and `/esplora` with `AdmissionOrigin::Esplora` and an explicit, fixed 10,000,000 sat/kvB request ceiling. Its hex body provides no fee override. RPC keeps its diff --git a/docs/contracts/mempool-mutations.md b/docs/contracts/mempool-mutations.md index 65cc8c355..72de31a33 100644 --- a/docs/contracts/mempool-mutations.md +++ b/docs/contracts/mempool-mutations.md @@ -213,7 +213,11 @@ state (`crates/mempool/src/orphan.rs`). Submission preserves the prepared consensus or policy error class; preview keeps its existing rejection facts. Refusal changes no membership, sequence, fee overlay, estimator or observer output. Four attempts bound stale facts. - It neither implements package RBF nor changes RPC/REST wire behavior. + It does not implement package RBF. RPC `submitpackage` uses this same atomic + owner for singletons and 1p1c, preserving independently accepted members + before reconsidering a fee-dependent pair. It captures resident/modified-fee + response facts under the same stamp and applies the RPC modified-feerate cap + before scripts; preview and native caller-cap semantics are unchanged. - Proof: `gateway::tests::chain_generation_starts_even_at_zero`, `gateway::tests::explicit_finish_restores_even_generation`, and `gateway::tests::chain_generation_overflow_fails_closed` in @@ -227,6 +231,11 @@ state (`crates/mempool/src/orphan.rs`). `package::tests::native_package_consensus_failures_match_single_submission` checks script and overspend error parity for single and multi-member native packages, unchanged preview facts and refusal without mutation. +- Proof: `package::tests::rpc_cpfp_commits_one_ordered_batch_with_actual_fees_and_pure_preview`, + `package::tests::rpc_package_keeps_independent_parent_but_never_uses_its_fees_twice`, + and `package::tests::rpc_package_capacity_and_replacement_refusals_preserve_authoritative_state` + in `crates/mempool/src/package/tests.rs` cover RPC package admission, + resident fee exclusion, and refusal without mutation. - Chain facts come through `AdmissionChain` as provisional inputs for a submission attempt. The shared borrowed `ChainAdmissionView` in `crates/rpc/src/context.rs` reads the existing UTXO and block-tree handles, diff --git a/docs/contracts/mempool-policy.md b/docs/contracts/mempool-policy.md index c3b76b1ce..c873080f3 100644 --- a/docs/contracts/mempool-policy.md +++ b/docs/contracts/mempool-policy.md @@ -38,8 +38,15 @@ holds the reference pins and the admission-profile evidence. direct parents atomically, in arbitrary input order. New members pay one aggregate relay/pressure floor; resident or positively confirmed fees are excluded. It uses the same preparation, scripts, graph/TRUC and ephemeral-spend owners, with - no replacement or capacity eviction. RPC package submission remains - unsupported, not a hidden fallback to separate successful submissions. + no replacement or capacity eviction. `submit_rpc_package` uses this same + stamped atomic operation for a singleton or 1-parent-1-child package. Like + pinned Core `AcceptPackage`, it first admits individually eligible members + and then retries fee/missing-input failures together. An independently + accepted parent survives child refusal; a fee-dependent pair commits together. +- Proof: `package::tests::rpc_cpfp_commits_one_ordered_batch_with_actual_fees_and_pure_preview` + and `package::tests::rpc_package_keeps_independent_parent_but_never_uses_its_fees_twice` + in `crates/mempool/src/package/tests.rs` cover RPC package admission, + individual-first partial acceptance and resident fee exclusion. - Chain reads, script execution and fee-diagram solving run outside write locks. Callbacks run after pool/lifecycle locks are released. @@ -54,7 +61,9 @@ holds the reference pins and the admission-profile evidence. finality and graph policy precede scripts. The caller maximum fee guard runs only after successful admission verification, comparing the base fee to `CFeeRate::GetFee(vsize)` rather than a rounded rate quote. An invalid transaction cannot - select a more convenient maximum-fee error. + select a more convenient maximum-fee error. The `submit_rpc_package` + pre-script `check_package_max_feerate` is an explicit exception: for over-cap + members, `MaxFeeExceeded` precedes script-failure reporting. - A verified plan carries prepared insertion and ordered removals. The writer rechecks its stamp before any mutation. Capacity refusal, stale preparation or a rejected replacement changes no pool membership, fee overlay, estimator @@ -112,9 +121,17 @@ holds the reference pins and the admission-profile evidence. - Combined package previews enforce count/weight, txid uniqueness, dependency order, internal consistency and projected cluster bounds. First precheck failure leaves other rows unfinished. Script failures preserve only earlier - script-success rows. `submitpackage` stays `Unimplemented` and Esplora - `/txs/package` stays 404; native CPFP does not imply RPC compatibility or - package RBF support. + script-success rows. `submitpackage` supports singletons and 1p1c only; + wider packages and replacement are explicitly refused, and Esplora + `/txs/package` stays 404. RPC submission excludes resident fees, reports + different-witness residents, and caps each new member's modified feerate + before scripts. Native submission retains its existing base-fee guard and + confirmed-member deduplication; RPC treats confirmed transactions as new + input-resolution attempts. No package RBF or capacity eviction is implied. +- Proof: `package::tests::rpc_package_underpayment_priority_cap_and_unsupported_shapes_mutate_nothing` + and `package::tests::rpc_package_capacity_and_replacement_refusals_preserve_authoritative_state` + in `crates/mempool/src/package/tests.rs` cover package cap, + unsupported-shape, capacity and replacement outcomes. - TRUC requires matching unconfirmed versions, at most one ancestor/descendant, 10,000 adjusted vB for a root and 1,000 for a child. An eligible sole sibling joins single-transaction RBF and remains subject to its fees and diagrams. @@ -323,6 +340,14 @@ remain unsupported RPCs; this format makes no Core disk-format claim. - `crates/mempool/src/package/tests.rs`: 25/26 and 404,000/404,001 package boundaries, reference-checked weight, TRUC/cluster projections, zero-floor ephemeral spending and fee-only invalidation. +- `package::tests::rpc_cpfp_commits_one_ordered_batch_with_actual_fees_and_pure_preview` + and the `rpc_package_*` cases cover public submission's atomic pair, + independent parent success, fee revision/chain fences, capacity and victim + preservation. `policy_cases::submitpackage_cpfp_partial_acceptance_and_guards` + compares signed CPFP, sigop-adjusted fees, resident witness identities, + partial acceptance, TRUC/dust, burn guards, limits and mining projection + against the pinned Core process. Generic rejection diagnostics are compared + by their documented reason or failure class, not claimed byte-identical. - Gateway tests: publication order under concurrency/reentrancy, stale verdicts and rejected replacement preservation of estimator history and fee overlays. - Mining/RPC suites: shared chunks, block resource limits, witness commitment diff --git a/docs/policies/mempool-policy.md b/docs/policies/mempool-policy.md index 6159450fa..5d38b0ebd 100644 --- a/docs/policies/mempool-policy.md +++ b/docs/policies/mempool-policy.md @@ -73,22 +73,31 @@ Accepted rows include captured modified `effective-feerate` and their wtxid. Generic script errors and some numeric RPC error codes remain intentional compatibility differences. The registry keeps its `Deviation` claim on -`testmempoolaccept` while `submitpackage` stays `Unimplemented`; successful -selected cases do not certify their entire surface. +`testmempoolaccept` and scoped `submitpackage`; successful selected cases do +not certify their entire surface. ## 5. Allowed differences and unsupported cases The machine-readable list lives in `crates/rpc/core-compat.toml` under `admission_profile`. -- RPC `submitpackage` and package RBF submission remain unsupported; - the registry returns -32601 and Esplora `/txs/package` remains 404. Multi-row +- RPC `submitpackage` supports one transaction or one parent followed by its + child. Independent successes are committed first; only fee/missing-input + failures are reconsidered as an atomic CPFP pair. Resident fees cannot pay + again. Responses use the pinned v31 `package_msg`, wtxid-keyed `tx-results` + and `replaced-transactions` shape, including partial success and + `other-wtxid`. `maxfeerate` caps each new member's modified feerate; + `maxburnamount` is a per-output guard run over the whole request before + any admission. Wider packages, replacement (including singleton replacement), + and package capacity eviction are explicitly refused. Native virtual-size + capacity and generic consensus/script/numeric rejection details remain + deviations. Esplora `/txs/package` remains 404. Multi-row `testmempoolaccept` follows Core's non-aggregating test mode. Under the selected positive relay floor a zero-fee ephemeral parent cannot enter alone. The zero-floor preview tests prove dust spending rules. Native `MempoolGateway::submit_local_package` supports bounded child-with-independent- parents CPFP with aggregate fees, atomic refusal and no replacement/capacity eviction; `package::tests::native_package_*` owns that evidence. Its policy - is not a claim of Core `submitpackage` compatibility. + is separate from the RPC's individual-first result contract. - The graph owner computes exact optimal dependency closures, and the pool keeps each cluster's linearization current at every mutation so mining and eviction read a maintained chunk index instead of re-solving. Core uses bounded, diff --git a/docs/rpc-reference.md b/docs/rpc-reference.md index 6ebbf2be8..52c88de31 100644 --- a/docs/rpc-reference.md +++ b/docs/rpc-reference.md @@ -28,13 +28,13 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | `getblock` | 0.4.0 | Verbosity 2 fees and verbosity 3 prevouts use bounded certified undo. Older stale bodies whose receipt is no longer retained omit undo fields; transient history and explicit query budgets may refuse reads. Raw/txid shapes remain; stale block headers omit competing-branch successor links. See docs/contracts/external-api.md#retained-block-inputs. | | `verifychain` | 0.4.0 | Level 0 reads and decodes every requested non-genesis body and confirms it is the block the header chain names; level 1 also checks proof of work and the consensus-owned context-free body rules (stripped size, coinbase/transaction structure, money ranges, legacy sigops); level 2 also decodes the undo record; levels 3 and 4 run the level-2 checks and do not replay disconnect or reconnect. The answer is false for any damaged block or undo record and for windows this node does not hold (pruned, snapshot-only, unreadable or missing bodies) or skips on process shutdown mid-walk, matching Core's boolean failure for missing history rather than an incomplete-verification error for that case. Cancellation and recovery closure are -1 errors, not false: a triggered request latch answers -1 Block verification interrupted and a chainstate closed for recovery answers -1 Chainstate is unavailable for verification, including a closure that lands mid-walk or over an empty window. One owner serves RPC and startup; startup checks the last six blocks one level stronger, including witness commitments (crates/chainstate/src/verify.rs). See docs/contracts/external-api.md#block-database-verification. | | `scantxoutset` | 0.4.0 | Fixed addr/raw and public pkh/wpkh/sh(wpkh)/key-only tr descriptors, including wildcard/multipath expansion. Ranges are inclusive, defaulting to [0,1000] per wildcard path; fixed descriptors validate and ignore ranges. At most 1024 aggregate script expansions before deduplication; private keys, secret-dependent derivation and other forms remain unsupported. Cancellable traversal holds chain-transition exclusion; ancestry and response projection use the retained tip after release. Input, traversal, result and 60-second limits apply (docs/contracts/external-api.md, API-35). | -| `sendrawtransaction` | 0.4.0 | Core 31.1 replacement, modified-fee, cluster and TRUC cases are process-verified in overhaul_process_harness::policy_cases. Exact optimal graph ordering does not emulate Core transient SFL work-budget states. Capacity accounting and generic consensus error details retain the differences in docs/policies/mempool-policy.md; aggregate package submission is unsupported. | -| `testmempoolaccept` | 0.4.0 | Single preview shares committed admission verification. Core 31.1 package shape, dependency, fail-fast and replacement-disallowed cases are process-verified in overhaul_process_harness::policy_cases. Exact graph ordering, capacity behavior and generic error details retain the differences in docs/policies/mempool-policy.md. Aggregate package submission is unsupported. | +| `sendrawtransaction` | 0.4.0 | Core 31.1 replacement, modified-fee, cluster and TRUC cases are process-verified in overhaul_process_harness::policy_cases. Exact optimal graph ordering does not emulate Core transient SFL work-budget states. Capacity accounting and generic consensus error details retain the differences in docs/policies/mempool-policy.md; scoped package submission is exposed separately by submitpackage. | +| `testmempoolaccept` | 0.4.0 | Single preview shares committed admission verification. Core 31.1 package shape, dependency, fail-fast and replacement-disallowed cases are process-verified in overhaul_process_harness::policy_cases. Exact graph ordering, capacity behavior and generic error details retain the differences in docs/policies/mempool-policy.md. Scoped package submission is exposed separately by submitpackage. | | `createrawtransaction` | 0.4.0 | Accepts inputs, outputs, locktime and replaceable only. Version is fixed at 2 and replaceable defaults to false. Named version is rejected with -8; a fifth positional or args-prefix entry is rejected with -32602 after named binding. See docs/contracts/external-api.md#api-02-json-rpc-mechanics-and-the-wallet-free-surface. | | `getmempoolinfo` | 0.4.0 | Policy fields project the enforced MempoolPolicySnapshot: fullrbf is true and cluster bounds are enforced. optimal is always true for exact graph ordering rather than Core background SFL state. usage estimates local structures; maxmempool bounds virtual size rather than allocator usage. The rolling minimum fee follows Core eviction/trim bumps and post-block decay, measured against total virtual size. See docs/policies/mempool-policy.md. | | `gettxspendingprevout` | 0.11.0 | Mempool lookup and options follow Core 31.1 without txospenderindex. After named binding, missing outputs and excess positional arguments use local -32602 shape errors. Core uses -1 help for no arguments/excess arity but -3 for an outputs hole created by named options; explicit null matches Core -3. Name and collision errors retain Core precedence. See docs/contracts/external-api.md#api-32-gettxspendingprevout-mempool-snapshot. | -| `estimatesmartfee` | 0.4.0 | See docs/contracts/external-api.md#API-26 for conf_target and estimate_mode validation. The estimate comes from this node's mempool confirmation-history estimator with a 25-block horizon; estimate_mode is accepted and ignored (no ECONOMICAL/CONSERVATIVE split), and insufficient history returns an `errors` array (crates/rpc/src/handlers/util.rs). | -| `getrpcinfo` | 0.4.0 | active_commands is always an empty array; this node does not track in-flight RPC calls. logpath reports the configured debug log path (crates/rpc/src/handlers/util.rs). | +| `estimatesmartfee` | 0.4.0 | See docs/contracts/external-api.md#api-26-estimatesmartfee-parameters-and-esplora-fee-estimates for conf_target and estimate_mode validation. The estimate comes from this node's mempool confirmation-history estimator with a 25-block horizon; estimate_mode is accepted and ignored (no ECONOMICAL/CONSERVATIVE split), and insufficient history returns an `errors` array (crates/rpc/src/handlers/util.rs). | +| `getrpcinfo` | 0.4.0 | active_commands lists calls executing through Handler::dispatch (method and elapsed microseconds, oldest first, including this call); unknown or quota-refused methods never register. logpath reports the configured debug log path (crates/rpc/src/handlers/util.rs). | | `getmemoryinfo` | 0.4.0 | mode=mallocinfo is rejected with an invalid-parameter error instead of returning allocator XML (crates/rpc/src/handlers/util.rs). The figures are resident set size read from the OS, not Core's locked-pool allocator accounting. | | `estimaterawfee` | 0.4.0 | local_shape: the fee estimator does not expose Core decay/scale/pass/fail internals, so horizon objects carry feerate only and the no-estimate branch stays {} (crates/rpc/src/handlers/util.rs). All three horizons carry the same feerate where Core computes three independent estimates. | | `validateaddress` | 0.4.0 | local_shape (invalid branch): a malformed or wrong-network address is hand-built as Core's sparse {isvalid:false} object because corepc-types models the valid-only fields (address, scriptPubKey, isscript, iswitness) as required and cannot represent that wire shape; valid addresses round-trip the typed v31 contract (crates/rpc/src/handlers/util.rs). | @@ -50,6 +50,7 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | `waitforblockheight` | 0.13.0 | Durable active-tip waits. Timeout, cancellation, shutdown and recovery closure may return a durable in-progress reorg prefix. Arity errors retain Core -1 with concise usage rather than the full help body. Bounded admission returns -1 when full; wait responses close HTTP keep-alive. See docs/contracts/external-api.md#api-34-active-tip-waits. | | `waitfornewblock` | 0.13.0 | Durable active-tip waits. Timeout, cancellation, shutdown and recovery closure may return a durable in-progress reorg prefix. Arity errors retain Core -1 with concise usage rather than the full help body. Bounded admission returns -1 when full; wait responses close HTTP keep-alive. See docs/contracts/external-api.md#api-34-active-tip-waits. | | `decodescript` | 0.12.0 | Wallet-free Core script projection and wrapping. Invalid-curve keys inside complex Miniscript retain an address descriptor fallback instead of Core permissive wsh inference. Missing/extra arguments return compact usage rather than the full Core help body; direct calls enforce the existing 16 MiB RPC hex-input budget. Scoped Core process evidence covers script classes, descriptors, parameters, networks, and shared consumers (crates/rpc/src/handlers/util.rs and crates/rpc/src/tx_render.rs). | +| `submitpackage` | 0.14.0 | Scoped singleton and 1-parent-1-child CPFP submission through the existing atomic gateway, with Core individual-first partial acceptance and resident fee exclusion. Wider packages, replacement and capacity eviction are explicitly unsupported. Generic rejection details retain native differences; see docs/policies/mempool-policy.md. | ### Implemented (unverified) @@ -97,16 +98,18 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | `submitblock` | 0.4.0 | Decode failures are -22 (Block decode failed). Extra bytes after a complete block and BIP22's dummy second argument are ignored. A header already admitted by submitheader still accepts the body; a previously connected body (scripts-valid), including after a later reorg, is duplicate. | | `submitheader` | 0.4.0 | See API-13 in docs/contracts/external-api.md for submitheader behavior. | | `prioritisetransaction` | 0.4.0 | Dummy (params[1]) must be 0 or null; fee_delta is params[2]. Non-zero dummy is Core -8. Pooled dust outputs are -8 except on regtest. See API-23 in docs/contracts/external-api.md for dummy and fee_delta compatibility. | -| `generatetoaddress` | 0.4.0 | Assembles, solves, and submits n blocks paying the given address through the mining coordinator. Invalid-output behavior is defined in docs/contracts/external-api.md#API-29. | -| `generateblock` | 0.4.0 | Assembles and solves one block paying an address or descriptor from the listed mempool txids or raw txs in that order. See the canonical contract: docs/contracts/external-api.md#API-31. | +| `generatetoaddress` | 0.4.0 | Assembles, solves, and submits n blocks paying the given address through the mining coordinator. Invalid-output behavior is defined in docs/contracts/external-api.md#api-29-generate-invalid-output-text. | +| `generateblock` | 0.4.0 | Assembles and solves one block paying an address or descriptor from the listed mempool txids or raw txs in that order. See the canonical contract: docs/contracts/external-api.md#api-31-generateblock-multipath-ranged-and-expand-errors. | | `getnetworkhashps` | 0.4.0 | Estimated hashes/s over a caller-chosen lookback ending at a caller-chosen height; default lookback 120, height the applied tip. | -| `getprioritisedtransactions` | 0.4.0 | Projects the mempool's signed fee-delta overlay, including txids not currently pooled. See docs/contracts/external-api.md#API-28 for modified_fee units. | +| `getprioritisedtransactions` | 0.4.0 | Projects the mempool's signed fee-delta overlay, including txids not currently pooled. See docs/contracts/external-api.md#api-28-getprioritisedtransactions-modified_fee-is-satoshis for modified_fee units. | ### Extension | surface | since | notes | |---|---|---| | `getblocktweaks` | 0.14.0 | Experimental keyless BIP352 extension; requires --silent-payments. One applied-chain height or hash per call, certified retained inputs, final publication check, one in-flight projection, and shared block-render permits. Source read budgets can refuse valid blocks; use another complete source for those blocks. No draft HTTP/gRPC compatibility or wallet discovery claim. See docs/silent-payments.md. | +| `compressrawtransaction` | 0.14.0 | Optional BIP337 compressed-transaction encoding; disabled unless the node starts with --compressed-transactions, which answers -32603 (crates/rpc/src/compressed_tx.rs). | +| `decompressrawtransaction` | 0.14.0 | Optional BIP337 compressed-transaction decoding; disabled unless the node starts with --compressed-transactions, which answers -32603 (crates/rpc/src/compressed_tx.rs). | | `getcapabilities` | 0.4.0 | bitcoin-rs reporting of compiled/enabled concrete service capabilities and index lifecycle state (crates/rpc/src/handlers/chain.rs, crates/index/src/capabilities.rs). | ### Unimplemented @@ -136,7 +139,6 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | `getprivatebroadcastinfo` | n/a | Private-broadcast store not implemented. | | `joinpsbts` | n/a | PSBT merge not implemented (combine/finalize only). | | `signrawtransactionwithkey` | n/a | Signing requires key material this process never holds. | -| `submitpackage` | n/a | Aggregate CPFP and package RBF submission are intentionally unsupported; multi-row testmempoolaccept implements Core PackageTestAccept. See docs/policies/mempool-policy.md. | | `utxoupdatepsbt` | n/a | PSBT update from the UTXO set not implemented. | | `enumeratesigners` | n/a | No external signer support. | | `createmultisig` | n/a | No key material (policy). | @@ -240,4 +242,4 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm | `rawtx` | 0.4.0 | Requires the zmq feature and a --zmqpubrawtx endpoint. | | `sequence` | 0.4.0 | Requires the zmq feature and a --zmqpubsequence endpoint. Publishes C/D block events and A/R mempool events; A/R carry reversed txid, the label byte, and the mempool sequence as u64 LE (crates/rpc/src/zmq.rs). | -Row counts: Supported 0, Deviation 32, Implemented (unverified) 57, Extension 3, Disabled 0, Unimplemented 84 - total 176. +Row counts: Supported 0, Deviation 33, Implemented (unverified) 57, Extension 5, Disabled 0, Unimplemented 83 - total 178. diff --git a/e2e/src/node.rs b/e2e/src/node.rs index a56bcaaaf..2f3103502 100644 --- a/e2e/src/node.rs +++ b/e2e/src/node.rs @@ -335,6 +335,15 @@ fn launch_command( .arg(format!("-datadir={}", datadir.display())) .arg(format!("-bind={p2p_addr}")) .arg(format!("-rpcport={}", rpc_addr.port())) + // Pin the RPC listener to the exact probed endpoint. Core + // ignores -rpcbind unless -rpcallowip is also set, and + // without the pin a stolen IPv4 port leaves a half-alive + // node bound only on ::1: readiness probes 127.0.0.1 and + // hangs to the deadline instead of failing fast. Pinned, a + // stolen port is a fatal "Unable to start HTTP server" + // child exit, which the bounded spawn retry already owns. + .arg(format!("-rpcbind={rpc_addr}")) + .arg(format!("-rpcallowip={}", rpc_addr.ip())) .arg(format!("-mocktime={mock}")); ClockControl::Mock(mock) } @@ -401,6 +410,62 @@ fn exited_on_busy_port(evidence: &Path) -> bool { || stderr.contains("os error 10048") || stderr.contains("An attempt was made to access a socket") || stderr.contains("os error 10013") + // Pinned Core RPC bind loss: the fatal "Unable to start HTTP + // server" reaches stderr while the per-endpoint "Binding RPC on + // address ... failed" cause stays in stdout. The pair keeps the + // retry scoped to the port race: a lookup failure ("Address lookup + // failed") or an allow-list error never prints that warning line, + // so non-port startup failures still surface untouched. + || (stderr.contains("Unable to start HTTP server") + && fs::read_to_string(evidence.join("stdout.log")).is_ok_and(|stdout| { + stdout + .lines() + .any(|line| line.contains("Binding RPC on address") && line.contains("failed")) + })) +} + +/// Whether the child's own logs prove it bound the probed RPC endpoint: a +/// readiness probe may have been answered by whoever stole the port in the +/// reserve-release window (every harness node shares credentials), so a +/// reply alone never identifies our child. Core requires its attempt line +/// for our endpoint plus the post-bind `Starting HTTP server` record with +/// no `failed` line (the attempt alone also exists just before a fatal +/// exit); bitcoin-rs logs `rpc listener bound` with the address on +/// stderr. No bind proof yet means keep polling, never adopt. +fn rpc_bind_evidence_ready(kind: Kind, rpc_addr: SocketAddr, evidence: &Path) -> bool { + match kind { + Kind::Core => { + // Positive proof only: the attempt line alone also exists while + // the child is still alive just before its fatal exit, so its + // absence of `failed` proves nothing. `Starting HTTP server` + // runs strictly after the bind phase succeeds (31.1 order: + // attempts, then start), and the pin leaves exactly one + // endpoint, so this line means our address bound. + let attempt = format!( + "Binding RPC on address {} port {}", + rpc_addr.ip(), + rpc_addr.port() + ); + let Ok(stdout) = fs::read_to_string(evidence.join("stdout.log")) else { + return false; + }; + stdout.lines().any(|line| line.contains(&attempt)) + && stdout + .lines() + .any(|line| line.contains("Starting HTTP server")) + && !stdout + .lines() + .any(|line| line.contains("Binding RPC on address") && line.contains("failed")) + } + Kind::BitcoinRs => { + let Ok(stderr) = fs::read_to_string(evidence.join("stderr.log")) else { + return false; + }; + stderr.lines().any(|line| { + line.contains("rpc listener bound") && line.contains(&rpc_addr.to_string()) + }) + } + } } impl ProcessNode { @@ -556,7 +621,31 @@ impl ProcessNode { &json!([]), deadline.min(Instant::now() + REQUEST_TIMEOUT), ) { - Ok(_) => return Ok(()), + Ok(_) => { + // A successful probe is not proof our child answered: + // whoever stole the port in the reserve-release window + // may have, and every harness node shares the same + // credentials. Trust the probe only while our child is + // still alive; a dead child means the reply came from + // elsewhere, so surface the exit for the bounded retry + // instead of adopting a foreign node. + if let Some(status) = self.child.try_wait()? { + return Err(Error::ChildExit { + pid: self.pid(), + status, + evidence: self.evidence.clone(), + }); + } + // Liveness is not identity: our child may still be alive + // but not yet bound while the thief answers. Adopt the + // probe only once our child's own logs prove it bound + // the probed endpoint; until then keep polling. + if !rpc_bind_evidence_ready(self.kind, self.rpc_addr, &self.evidence) { + std::thread::sleep(Duration::from_millis(100)); + continue; + } + return Ok(()); + } Err(error) if Instant::now() >= deadline => { return Err(Error::Timeout { pid: self.pid(), @@ -980,4 +1069,104 @@ mod tests { ); Ok(()) } + + /// The pinned Core RPC-steal pair must match the port-race evidence and + /// nothing else: the fatal stderr line plus the per-endpoint stdout + /// warning retries, while a lookup failure or an unrelated exit never + /// does, so the bounded retry cannot fire on the wrong cause. + #[test] + fn busy_port_detector_matches_only_the_core_rpc_steal_pair() -> Result<()> { + fn evidence_with(stdout: &str, stderr: &str) -> Result<(tempfile::TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + fs::write(dir.path().join("stdout.log"), stdout)?; + fs::write(dir.path().join("stderr.log"), stderr)?; + let path = dir.path().to_path_buf(); + Ok((dir, path)) + } + + let (_keep, fatal) = evidence_with( + "2026-10-26T01:19:20Z [warning] Binding RPC on address 127.0.0.1 port 36079 failed.\n", + "Error: Unable to start HTTP server. See debug log for details.\n", + )?; + assert!(exited_on_busy_port(&fatal)); + + let (_keep, lookup) = evidence_with( + "2026-10-26T01:19:20Z Binding RPC on address 127.0.0.1 port 36079\n2026-10-26T01:19:20Z Could not bind RPC on address 127.0.0.1 port 36079: Address lookup failed.\n", + "Error: Unable to start HTTP server. See debug log for details.\n", + )?; + assert!( + !exited_on_busy_port(&lookup), + "a lookup failure is not the port race" + ); + + let (_keep, other) = evidence_with( + "error: unexpected argument '--bogus'\n", + "error: unexpected argument '--bogus'\n", + )?; + assert!( + !exited_on_busy_port(&other), + "an unrelated startup failure must surface untouched" + ); + Ok(()) + } + + /// Readiness adopts a probe only with the child's own bind proof: a + /// same-credential thief can answer while our child is still alive but + /// unbound, so the reply alone never identifies our node. + #[test] + fn readiness_requires_the_child_own_bind_evidence() -> Result<()> { + fn evidence_with(stdout: &str, stderr: &str) -> Result<(tempfile::TempDir, PathBuf)> { + let dir = tempfile::tempdir()?; + fs::write(dir.path().join("stdout.log"), stdout)?; + fs::write(dir.path().join("stderr.log"), stderr)?; + let path = dir.path().to_path_buf(); + Ok((dir, path)) + } + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 36079)); + let (_keep, core_bound) = evidence_with( + "2026-10-26T01:19:20Z Binding RPC on address 127.0.0.1 port 36079\n2026-10-26T01:19:20Z Starting HTTP server with 16 worker threads\n", + "", + )?; + assert!(rpc_bind_evidence_ready(Kind::Core, addr, &core_bound)); + + let (_keep, core_attempt_only) = evidence_with( + "2026-10-26T01:19:20Z Binding RPC on address 127.0.0.1 port 36079\n", + "", + )?; + assert!( + !rpc_bind_evidence_ready(Kind::Core, addr, &core_attempt_only), + "an attempt without the started record must not adopt: the child may die next" + ); + + let (_keep, core_pending) = + evidence_with("2026-10-26T01:19:20Z Bound to 127.0.0.1:37681\n", "")?; + assert!( + !rpc_bind_evidence_ready(Kind::Core, addr, &core_pending), + "a pre-bind probe must keep polling, never adopt" + ); + + let (_keep, core_lost) = evidence_with( + "2026-10-26T01:19:20Z Binding RPC on address 127.0.0.1 port 36079\n2026-10-26T01:19:20Z [warning] Binding RPC on address 127.0.0.1 port 36079 failed.\n", + "", + )?; + assert!( + !rpc_bind_evidence_ready(Kind::Core, addr, &core_lost), + "a lost bind must never read as ready" + ); + + let (_keep, node_bound) = evidence_with( + "", + "2026-10-26T01:19:20Z INFO bitcoin_rs_node::lifecycle: rpc listener bound addr=127.0.0.1:36079\n", + )?; + assert!(rpc_bind_evidence_ready(Kind::BitcoinRs, addr, &node_bound)); + + let (_keep, node_pending) = + evidence_with("", "2026-10-26T01:19:20Z INFO startup: loading\n")?; + assert!( + !rpc_bind_evidence_ready(Kind::BitcoinRs, addr, &node_pending), + "a pre-bind probe must keep polling, never adopt" + ); + Ok(()) + } } diff --git a/e2e/tests/lifecycle.rs b/e2e/tests/lifecycle.rs index 7892a775e..cf06e5552 100644 --- a/e2e/tests/lifecycle.rs +++ b/e2e/tests/lifecycle.rs @@ -37,7 +37,14 @@ fn startup_reports_regtest_identity() -> Result<()> { fn node_info_rpcs_answer() -> Result<()> { let mut node = ProcessNode::spawn(Kind::BitcoinRs)?; let rpcinfo = node.rpc("getrpcinfo", &json!([]))?; - assert_eq!(rpcinfo.field("active_commands")?, &json!([])); + let active = rpcinfo.field("active_commands")?; + assert!( + active == &json!([]) + || active.as_array().is_some_and(|commands| { + commands.len() == 1 && commands[0]["method"] == json!("getrpcinfo") + }), + "unexpected active commands: {active:?}" + ); let datadir = node.take_datadir()?; assert_eq!( rpcinfo.str_field("logpath")?, diff --git a/e2e/tests/scantxoutset.rs b/e2e/tests/scantxoutset.rs index 15170a7cc..b6247f6be 100644 --- a/e2e/tests/scantxoutset.rs +++ b/e2e/tests/scantxoutset.rs @@ -76,6 +76,18 @@ fn fixed_descriptors_scan_identical_core_created_coins() -> Result<()> { }), )?; } + compare_scan( + &mut core, + &mut node, + "ranged-tpub-scan", + json!({ + "action": "start", + "scanobjects": [{ + "desc": "wpkh([deadbeef/1h/2]tpubD6NzVbkrYhZ4WaWSyoBvQwbpLkojyoTZPRsgXELWz3Popb3qkjcJyJUGLnL4qHHoQvao8ESaAstxYSnhyswJ76uZPStJRJCTKvosUCJZL5B/0/*)", + "range": [0, 8] + }] + }), + )?; compare_scan( &mut core, &mut node,