diff --git a/flake.lock b/flake.lock index 6d1fa8e96..253916520 100644 --- a/flake.lock +++ b/flake.lock @@ -1,17 +1,12 @@ { "nodes": { "crane": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, "locked": { - "lastModified": 1711586303, - "narHash": "sha256-iZDHWTqQj6z6ccqTSEOPOxQ8KMFAemInUObN2R9vHSs=", + "lastModified": 1754269165, + "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", "owner": "ipetkov", "repo": "crane", - "rev": "a329cd00398379c62e76fc3b8d4ec2934260d636", + "rev": "444e81206df3f7d92780680e45858e31d2f07a08", "type": "github" }, "original": { @@ -25,11 +20,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -40,11 +35,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1711523803, - "narHash": "sha256-UKcYiHWHQynzj6CN/vTcix4yd1eCu1uFdsuarupdCQQ=", + "lastModified": 1744463964, + "narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2726f127c15a4cc9810843b96cad73c7eb39e443", + "rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", "type": "github" }, "original": { @@ -64,19 +59,16 @@ }, "rust-overlay": { "inputs": { - "flake-utils": [ - "flake-utils" - ], "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1711592024, - "narHash": "sha256-oD4OJ3TRmVrbAuKZWxElRCyCagNCDuhfw2exBmNOy48=", + "lastModified": 1754966322, + "narHash": "sha256-7f/LH60DnjjQVKbXAsHIniGaU7ixVM7eWU3hyjT24YI=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "aa858717377db2ed8ffd2d44147d907baee656e5", + "rev": "7c13cec2e3828d964b9980d0ffd680bd8d4dce90", "type": "github" }, "original": { diff --git a/src/daemon.rs b/src/daemon.rs index 84b93ac03..9b4bb6478 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -141,6 +141,34 @@ struct NetworkInfo { relayfee: f64, // in BTC/kB } +#[derive(Serialize, Deserialize, Debug)] +struct MempoolFeesSubmitPackage { + base: f64, + #[serde(rename = "effective-feerate")] + effective_feerate: Option, + #[serde(rename = "effective-includes")] + effective_includes: Option>, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SubmitPackageResult { + package_msg: String, + #[serde(rename = "tx-results")] + tx_results: HashMap, + #[serde(rename = "replaced-transactions")] + replaced_transactions: Option>, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct TxResult { + txid: String, + #[serde(rename = "other-wtxid")] + other_wtxid: Option, + vsize: Option, + fees: Option, + error: Option, +} + pub trait CookieGetter: Send + Sync { fn get(&self) -> Result>; } @@ -671,6 +699,25 @@ impl Daemon { ) } + pub fn submit_package( + &self, + txhex: Vec, + maxfeerate: Option, + maxburnamount: Option, + ) -> Result { + let params = match (maxfeerate, maxburnamount) { + (Some(rate), Some(burn)) => { + json!([txhex, format!("{:.8}", rate), format!("{:.8}", burn)]) + } + (Some(rate), None) => json!([txhex, format!("{:.8}", rate)]), + (None, Some(burn)) => json!([txhex, null, format!("{:.8}", burn)]), + (None, None) => json!([txhex]), + }; + let result = self.request("submitpackage", params)?; + serde_json::from_value::(result) + .chain_err(|| "invalid submitpackage reply") + } + // Get estimated feerates for the provided confirmation targets using a batch RPC request // Missing estimates are logged but do not cause a failure, whatever is available is returned #[allow(clippy::float_cmp)] diff --git a/src/new_index/query.rs b/src/new_index/query.rs index df258bea9..03c5d201f 100644 --- a/src/new_index/query.rs +++ b/src/new_index/query.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use crate::chain::{Network, OutPoint, Transaction, TxOut, Txid}; use crate::config::Config; -use crate::daemon::Daemon; +use crate::daemon::{Daemon, SubmitPackageResult}; use crate::errors::*; use crate::new_index::{ChainQuery, Mempool, ScriptStats, SpendingInput, Utxo}; use crate::util::{is_spendable, BlockId, Bytes, TransactionStatus}; @@ -82,6 +82,16 @@ impl Query { Ok(txid) } + #[trace] + pub fn submit_package( + &self, + txhex: Vec, + maxfeerate: Option, + maxburnamount: Option, + ) -> Result { + self.daemon.submit_package(txhex, maxfeerate, maxburnamount) + } + #[trace] pub fn utxo(&self, scripthash: &[u8]) -> Result> { let mut utxos = self.chain.utxo(scripthash, self.config.utxos_limit)?; diff --git a/src/rest.rs b/src/rest.rs index b7365d0bc..cefc49b7c 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -16,7 +16,7 @@ use crate::util::{ use bitcoin::consensus::encode; use bitcoin::hashes::FromSliceError as HashError; -use bitcoin::hex::{self, DisplayHex, FromHex}; +use bitcoin::hex::{self, DisplayHex, FromHex, HexToBytesIter}; use hyper::service::{make_service_fn, service_fn}; use hyper::{Body, Method, Response, Server, StatusCode}; use hyperlocal::UnixServerExt; @@ -1018,6 +1018,63 @@ fn handle_request( let txid = query.broadcast_raw(&txhex)?; http_message(StatusCode::OK, txid.to_string(), 0) } + (&Method::POST, Some(&"txs"), Some(&"package"), None, None, None) => { + let txhexes: Vec = + serde_json::from_str(String::from_utf8(body.to_vec())?.as_str())?; + + if txhexes.len() > 25 { + Result::Err(HttpError::from( + "Exceeded maximum of 25 transactions".to_string(), + ))? + } + + let maxfeerate = query_params + .get("maxfeerate") + .map(|s| { + s.parse::() + .map_err(|_| HttpError::from("Invalid maxfeerate".to_string())) + }) + .transpose()?; + + let maxburnamount = query_params + .get("maxburnamount") + .map(|s| { + s.parse::() + .map_err(|_| HttpError::from("Invalid maxburnamount".to_string())) + }) + .transpose()?; + + // pre-checks + txhexes.iter().enumerate().try_for_each(|(index, txhex)| { + // each transaction must be of reasonable size + // (more than 60 bytes, within 400kWU standardness limit) + if !(120..800_000).contains(&txhex.len()) { + Result::Err(HttpError::from(format!( + "Invalid transaction size for item {}", + index + ))) + } else { + // must be a valid hex string + HexToBytesIter::new(txhex) + .map_err(|_| { + HttpError::from(format!("Invalid transaction hex for item {}", index)) + })? + .filter(|r| r.is_err()) + .next() + .transpose() + .map_err(|_| { + HttpError::from(format!("Invalid transaction hex for item {}", index)) + }) + .map(|_| ()) + } + })?; + + let result = query + .submit_package(txhexes, maxfeerate, maxburnamount) + .map_err(|err| HttpError::from(err.description().to_string()))?; + + json_response(result, TTL_SHORT) + } (&Method::GET, Some(&"mempool"), None, None, None, None) => { json_response(query.mempool().backlog_stats(), TTL_SHORT) diff --git a/tests/rest.rs b/tests/rest.rs index f72b9c475..90502f8f6 100644 --- a/tests/rest.rs +++ b/tests/rest.rs @@ -185,11 +185,171 @@ fn test_rest() -> Result<()> { let broadcast2_res = ureq::post(&format!("http://{}/tx", rest_addr)).send_string(&tx_hex); let broadcast2_resp = broadcast2_res.unwrap_err().into_response().unwrap(); assert_eq!(broadcast2_resp.status(), 400); + + // Test POST /txs/package - simple validation test + // Test with invalid JSON first to verify the endpoint exists + let invalid_package_result = ureq::post(&format!("http://{}/txs/package", rest_addr)) + .set("Content-Type", "application/json") + .send_string("invalid json"); + let invalid_package_resp = invalid_package_result.unwrap_err().into_response().unwrap(); + let status = invalid_package_resp.status(); + // Should be 400 for bad JSON, not 404 for missing endpoint assert_eq!( - broadcast2_resp.into_string()?, - "sendrawtransaction RPC error -27: Transaction already in block chain" + status, 400, + "Endpoint should exist and return 400 for invalid JSON" ); + // Now test with valid but empty package, should fail + let empty_package_result = ureq::post(&format!("http://{}/txs/package", rest_addr)) + .set("Content-Type", "application/json") + .send_string("[]"); + let empty_package_resp = empty_package_result.unwrap_err().into_response().unwrap(); + let status = empty_package_resp.status(); + assert_eq!(status, 400); + + // Elements-only tests + #[cfg(not(feature = "liquid"))] + { + let network_info = tester.node_client().call::("getnetworkinfo", &[])?; + let version = network_info["version"].as_u64().expect("network version"); + if version >= 280000 { + // Test with a real transaction package - create parent-child transactions + // submitpackage requires between 2 and 25 transactions with proper dependencies + let package_addr1 = tester.newaddress()?; + let package_addr2 = tester.newaddress()?; + + // Create parent transaction + let tx1_result = tester.node_client().call::( + "createrawtransaction", + &[ + serde_json::json!([]), + serde_json::json!({package_addr1.to_string(): 0.5}), + ], + )?; + let tx1_unsigned_hex = tx1_result.as_str().expect("raw tx hex").to_string(); + + let tx1_fund_result = tester + .node_client() + .call::("fundrawtransaction", &[serde_json::json!(tx1_unsigned_hex)])?; + let tx1_funded_hex = tx1_fund_result["hex"] + .as_str() + .expect("funded tx hex") + .to_string(); + + let tx1_sign_result = tester.node_client().call::( + "signrawtransactionwithwallet", + &[serde_json::json!(tx1_funded_hex)], + )?; + let tx1_signed_hex = tx1_sign_result["hex"] + .as_str() + .expect("signed tx hex") + .to_string(); + + // Decode parent transaction to get its txid and find the output to spend + let tx1_decoded = tester + .node_client() + .call::("decoderawtransaction", &[serde_json::json!(tx1_signed_hex)])?; + let tx1_txid = tx1_decoded["txid"].as_str().expect("parent txid"); + + // Find the output going to package_addr1 (the one we want to spend) + let tx1_vouts = tx1_decoded["vout"].as_array().expect("parent vouts"); + let mut spend_vout_index = None; + let mut spend_vout_value = 0u64; + + for (i, vout) in tx1_vouts.iter().enumerate() { + if let Some(script_pub_key) = vout.get("scriptPubKey") { + if let Some(address) = script_pub_key.get("address") { + if address.as_str() == Some(&package_addr1.to_string()) { + spend_vout_index = Some(i); + // Convert from BTC to satoshis + spend_vout_value = (vout["value"].as_f64().expect("vout value") + * 100_000_000.0) + as u64; + break; + } + } + } + } + + let spend_vout_index = spend_vout_index.expect("Could not find output to spend"); + + // Create child transaction that spends from parent + // Leave some satoshis for fee (e.g., 1000 sats) + let child_output_value = spend_vout_value - 1000; + let child_output_btc = child_output_value as f64 / 100_000_000.0; + + let tx2_result = tester.node_client().call::( + "createrawtransaction", + &[ + serde_json::json!([{ + "txid": tx1_txid, + "vout": spend_vout_index + }]), + serde_json::json!({package_addr2.to_string(): child_output_btc}), + ], + )?; + let tx2_unsigned_hex = tx2_result.as_str().expect("raw tx hex").to_string(); + + // Sign the child transaction + // We need to provide the parent transaction's output details for signing + let tx2_sign_result = tester.node_client().call::( + "signrawtransactionwithwallet", + &[ + serde_json::json!(tx2_unsigned_hex), + serde_json::json!([{ + "txid": tx1_txid, + "vout": spend_vout_index, + "scriptPubKey": tx1_vouts[spend_vout_index]["scriptPubKey"]["hex"].as_str().unwrap(), + "amount": spend_vout_value as f64 / 100_000_000.0 + }]) + ], + )?; + let tx2_signed_hex = tx2_sign_result["hex"] + .as_str() + .expect("signed tx hex") + .to_string(); + + // Debug: try calling submitpackage directly to see the result + eprintln!("Trying submitpackage directly with parent-child transactions..."); + let direct_result = tester.node_client().call::( + "submitpackage", + &[serde_json::json!([ + tx1_signed_hex.clone(), + tx2_signed_hex.clone() + ])], + ); + match direct_result { + Ok(result) => { + eprintln!("Direct submitpackage succeeded: {:#?}", result); + } + Err(e) => { + eprintln!("Direct submitpackage failed: {:?}", e); + } + } + + // Now submit this transaction package via the package endpoint + let package_json = + serde_json::json!([tx1_signed_hex.clone(), tx2_signed_hex.clone()]).to_string(); + let package_result = ureq::post(&format!("http://{}/txs/package", rest_addr)) + .set("Content-Type", "application/json") + .send_string(&package_json); + + let package_resp = package_result.unwrap(); + assert_eq!(package_resp.status(), 200); + let package_result = package_resp.into_json::()?; + + // Verify the response structure + assert!(package_result["tx-results"].is_object()); + assert!(package_result["package_msg"].is_string()); + + let tx_results = package_result["tx-results"].as_object().unwrap(); + assert_eq!(tx_results.len(), 2); + + // The transactions should be processed (whether accepted or rejected) + assert!(!tx_results.is_empty()); + } + } + // Elements-only tests #[cfg(feature = "liquid")] {