From 6a7857972a8828af5e023361024c1c85b8878b6e Mon Sep 17 00:00:00 2001 From: GideonBature Date: Wed, 25 Jun 2025 17:26:32 +0100 Subject: [PATCH] Enforce content-length validation on sender and size limits on payjoin-cli --- Cargo-minimal.lock | 52 ++++++++++++++++++-- Cargo-recent.lock | 52 ++++++++++++++++++-- payjoin-cli/Cargo.toml | 6 ++- payjoin-cli/src/app/mod.rs | 25 ++++++++++ payjoin-cli/src/app/v1.rs | 50 ++++++++++++++++---- payjoin-cli/src/app/v2/mod.rs | 2 +- payjoin/src/core/send/error.rs | 8 +--- payjoin/src/core/send/mod.rs | 86 +++++++++++++++++++++++++++++++++- payjoin/src/core/send/v1.rs | 59 +++++++++-------------- payjoin/tests/integration.rs | 6 ++- 10 files changed, 280 insertions(+), 66 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 8e6c24932..f796c2555 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -2569,6 +2569,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "log", "nix", "payjoin", "payjoin-test-utils", @@ -2577,6 +2578,7 @@ dependencies = [ "rcgen", "reqwest", "rusqlite", + "rustls 0.22.4", "serde", "serde_json", "sled", @@ -3155,6 +3157,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3175,12 +3178,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.2", ] @@ -3287,6 +3292,20 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.31" @@ -3344,6 +3363,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.4" @@ -4682,12 +4712,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] @@ -4724,11 +4755,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 8e6c24932..f796c2555 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -2569,6 +2569,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "log", "nix", "payjoin", "payjoin-test-utils", @@ -2577,6 +2578,7 @@ dependencies = [ "rcgen", "reqwest", "rusqlite", + "rustls 0.22.4", "serde", "serde_json", "sled", @@ -3155,6 +3157,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -3175,12 +3178,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.2", ] @@ -3287,6 +3292,20 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.31" @@ -3344,6 +3363,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.4" @@ -4682,12 +4712,13 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] @@ -4724,11 +4755,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index d4d2f4db1..bdc952e53 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -23,7 +23,7 @@ default = ["v2"] native-certs = ["reqwest/rustls-tls-native-roots"] _manual-tls = ["rcgen", "reqwest/rustls-tls", "hyper-rustls", "payjoin/_manual-tls", "tokio-rustls"] v1 = ["payjoin/v1","hyper", "hyper-util", "http-body-util"] -v2 = ["payjoin/v2", "payjoin/io"] +v2 = ["payjoin/v2", "payjoin/io", "hyper"] [dependencies] anyhow = "1.0.99" @@ -40,7 +40,7 @@ payjoin = { version = "0.24.0", default-features = false } r2d2 = "0.8.10" r2d2_sqlite = "0.22.0" rcgen = { version = "0.14.3", optional = true } -reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12.23", default-features = false, features = ["json", "rustls-tls", "stream"] } rusqlite = { version = "0.29.0", features = ["bundled"] } serde_json = "1.0.142" serde = { version = "1.0.219", features = ["derive"] } @@ -51,6 +51,8 @@ url = { version = "2.5.4", features = ["serde"] } dirs = "6.0.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +rustls = { version = "0.22.4", optional = true } +log = "0.4.27" [dev-dependencies] nix = { version = "0.30.1", features = ["aio", "process", "signal"] } diff --git a/payjoin-cli/src/app/mod.rs b/payjoin-cli/src/app/mod.rs index 2c7898d8b..a11aabaaf 100644 --- a/payjoin-cli/src/app/mod.rs +++ b/payjoin-cli/src/app/mod.rs @@ -1,6 +1,12 @@ use std::collections::HashMap; +#[cfg(feature = "v1")] +use anyhow::anyhow; use anyhow::Result; +#[cfg(feature = "v1")] +use futures::{Stream, StreamExt}; +#[cfg(feature = "v1")] +use hyper::body::Bytes; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{self, Address, Amount, FeeRate}; use tokio::signal; @@ -82,3 +88,22 @@ async fn handle_interrupt(tx: watch::Sender<()>) { } let _ = tx.send(()); } + +#[cfg(feature = "v1")] +pub async fn read_limited_body(mut stream: S, expected_len: usize) -> Result> +where + S: Stream> + Unpin, + E: std::error::Error + Send + Sync + 'static, +{ + let mut body = Vec::with_capacity(expected_len); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| anyhow!("Error reading body chunk: {}", e))?; + if body.len() + chunk.len() > expected_len { + return Err(anyhow!("Body exceeds expected size of {expected_len} bytes")); + } + body.extend_from_slice(&chunk); + } + + Ok(body) +} diff --git a/payjoin-cli/src/app/v1.rs b/payjoin-cli/src/app/v1.rs index 4958afe24..7be60974c 100644 --- a/payjoin-cli/src/app/v1.rs +++ b/payjoin-cli/src/app/v1.rs @@ -23,9 +23,14 @@ use tokio::sync::watch; use super::config::Config; use super::wallet::BitcoindWallet; use super::App as AppTrait; -use crate::app::{handle_interrupt, http_agent}; +use crate::app::{handle_interrupt, http_agent, read_limited_body}; use crate::db::Database; +/// 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length +/// 4_000_000 * 4 / 3 fits in u32 +const MAX_CONTENT_LENGTH: usize = 4_000_000 * 4 / 3; + +#[derive(Clone)] struct Headers<'a>(&'a hyper::HeaderMap); impl payjoin::receive::v1::Headers for Headers<'_> { fn get_header(&self, key: &str) -> Option<&str> { @@ -86,8 +91,22 @@ impl AppTrait for App { "Sent fallback transaction hex: {:#}", payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) ); - let psbt = ctx.process_response(&response.bytes().await?).map_err(|e| { - tracing::debug!("Error processing response: {e:?}"); + + let expected_length = response + .headers() + .get("Content-Length") + .and_then(|val| val.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(MAX_CONTENT_LENGTH); + + if expected_length > MAX_CONTENT_LENGTH { + return Err(anyhow!("Response body is too large: {} bytes", expected_length)); + } + + let body = read_limited_body(response.bytes_stream(), MAX_CONTENT_LENGTH).await?; + + let psbt = ctx.process_response(&body).map_err(|e| { + log::debug!("Error processing response: {e:?}"); anyhow!("Failed to process response {e}") })?; @@ -291,12 +310,27 @@ impl App { ) -> Result>, Error> { let (parts, body) = req.into_parts(); let headers = Headers(&parts.headers); + + let expected_length = headers + .0 + .get("Content-Length") + .and_then(|val| val.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(MAX_CONTENT_LENGTH); + + if expected_length > MAX_CONTENT_LENGTH { + log::error!("Error: Content length exceeds max allowed"); + return Err(Error::Implementation(ImplementationError::from( + anyhow!("Content length too large: {expected_length}").into_boxed_dyn_error(), + ))); + } + + let body = + read_limited_body(body.into_data_stream(), expected_length).await.map_err(|e| { + Error::Implementation(ImplementationError::from(e.into_boxed_dyn_error())) + })?; + let query_string = parts.uri.query().unwrap_or(""); - let body = body - .collect() - .await - .map_err(|e| Error::Implementation(ImplementationError::new(e)))? - .to_bytes(); let proposal = UncheckedOriginalPayload::from_request(&body, query_string, headers)?; let payjoin_proposal = self.process_v1_proposal(proposal)?; diff --git a/payjoin-cli/src/app/v2/mod.rs b/payjoin-cli/src/app/v2/mod.rs index 5562dec33..e088b14e2 100644 --- a/payjoin-cli/src/app/v2/mod.rs +++ b/payjoin-cli/src/app/v2/mod.rs @@ -71,7 +71,7 @@ impl AppTrait for App { let psbt = self.create_original_psbt(&address, amount, fee_rate)?; let (req, ctx) = payjoin::send::v1::SenderBuilder::from_parts( psbt, - pj_param, + &PjParam::V1(pj_param.clone()), &address, Some(amount), ) diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 13a02910d..d0094ea67 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -94,7 +94,6 @@ pub struct ValidationError(InternalValidationError); pub(crate) enum InternalValidationError { #[cfg(feature = "v1")] Parse, - #[cfg(feature = "v1")] ContentTooLarge, Proposal(InternalProposalError), #[cfg(feature = "v2")] @@ -120,11 +119,7 @@ impl fmt::Display for ValidationError { match &self.0 { #[cfg(feature = "v1")] Parse => write!(f, "couldn't decode as PSBT or JSON",), - #[cfg(feature = "v1")] - ContentTooLarge => { - use crate::MAX_CONTENT_LENGTH; - write!(f, "content is larger than {MAX_CONTENT_LENGTH} bytes") - } + ContentTooLarge => write!(f, "The response body is too large"), Proposal(e) => write!(f, "proposal PSBT error: {e}"), #[cfg(feature = "v2")] V2Encapsulation(e) => write!(f, "v2 encapsulation error: {e}"), @@ -139,7 +134,6 @@ impl std::error::Error for ValidationError { match &self.0 { #[cfg(feature = "v1")] Parse => None, - #[cfg(feature = "v1")] ContentTooLarge => None, Proposal(e) => Some(e), #[cfg(feature = "v2")] diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 52b9c621d..cad88e09a 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -16,6 +16,8 @@ //! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), //! but request reuse makes correlation trivial for the relay. +use std::str::FromStr; + use bitcoin::psbt::Psbt; use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; pub use error::{BuildSenderError, ResponseError, ValidationError, WellKnownError}; @@ -24,7 +26,7 @@ use url::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; -use crate::Version; +use crate::{Version, MAX_CONTENT_LENGTH}; // See usize casts #[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))] @@ -674,6 +676,32 @@ fn serialize_url( url } +/// Data required to validate the response. +/// +/// This type is used to process a BIP78 response. +/// Call [`Self::process_response`] on it to continue the BIP78 flow. +#[derive(Debug, Clone)] +pub struct V1Context { + psbt_context: PsbtContext, +} + +impl V1Context { + /// Decodes and validates the response. + /// + /// Call this method with response from receiver to continue BIP78 flow. If the response is + /// valid you will get appropriate PSBT that you should sign and broadcast. + #[inline] + pub fn process_response(self, response: &[u8]) -> Result { + if response.len() > MAX_CONTENT_LENGTH { + return Err(ResponseError::from(InternalValidationError::ContentTooLarge)); + } + + let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?; + let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?; + self.psbt_context.process_proposal(proposal).map_err(Into::into) + } +} + #[cfg(test)] mod test { use bitcoin::absolute::LockTime; @@ -709,6 +737,62 @@ mod test { }) } + #[test] + fn response_len_under_limit_is_not_content_too_large() -> Result<(), BoxError> { + let ctx = V1Context { psbt_context: create_psbt_context()? }; + + let psbt_str = PARSED_ORIGINAL_PSBT.clone().to_string(); + assert!(psbt_str.len() < MAX_CONTENT_LENGTH); + + let result = ctx.clone().process_response(psbt_str.as_bytes()); + + let err_str = result.as_ref().err().map(|e| e.to_string()).unwrap_or_default(); + assert_ne!( + err_str, + "The receiver sent an invalid response: The response body is too large" + ); + + Ok(()) + } + + #[test] + fn response_len_equal_limit_is_not_content_too_large() -> Result<(), BoxError> { + let ctx = V1Context { psbt_context: create_psbt_context()? }; + + let mut psbt_str = PARSED_ORIGINAL_PSBT.clone().to_string(); + + if psbt_str.len() < MAX_CONTENT_LENGTH { + psbt_str.push_str(&" ".repeat(MAX_CONTENT_LENGTH - psbt_str.len())); + } + assert_eq!(psbt_str.len(), MAX_CONTENT_LENGTH); + + let result = ctx.clone().process_response(psbt_str.as_bytes()); + + let err_str = result.as_ref().err().map(|e| e.to_string()).unwrap_or_default(); + assert_ne!( + err_str, + "The receiver sent an invalid response: The response body is too large" + ); + + Ok(()) + } + + #[test] + fn response_len_over_limit_is_content_too_large() -> Result<(), BoxError> { + let ctx = V1Context { psbt_context: create_psbt_context()? }; + + let too_long = vec![b'a'; MAX_CONTENT_LENGTH + 1]; + let result = ctx.process_response(&too_long); + + let err_str = result.unwrap_err().to_string(); + assert_eq!( + err_str, + "The receiver sent an invalid response: The response body is too large" + ); + + Ok(()) + } + #[test] fn test_restore_original_utxos() -> Result<(), BoxError> { let mut original_psbt = PARSED_ORIGINAL_PSBT.clone(); diff --git a/payjoin/src/core/send/v1.rs b/payjoin/src/core/send/v1.rs index 97543b0af..9c3b59151 100644 --- a/payjoin/src/core/send/v1.rs +++ b/payjoin/src/core/send/v1.rs @@ -31,8 +31,10 @@ use url::Url; use super::*; use crate::error_codes::ErrorCode; pub use crate::output_substitution::OutputSubstitution; -use crate::uri::v1::PjParam; -use crate::{PjUri, Request, MAX_CONTENT_LENGTH}; +#[allow(unused_imports)] +use crate::psbt::PsbtExt; +pub use crate::MAX_CONTENT_LENGTH; +use crate::{PjParam, PjUri, Request}; /// A builder to construct the properties of a `Sender`. #[derive(Clone)] @@ -395,6 +397,16 @@ mod test { Ok(()) } + #[test] + fn test_max_content_length() { + assert_eq!(MAX_CONTENT_LENGTH, 4_000_000 * 4 / 3); + } + + #[test] + fn test_non_witness_input_weight_const() { + assert_eq!(NON_WITNESS_INPUT_WEIGHT, bitcoin::Weight::from_wu(160)); + } + /// This test is to make sure that the input_pairs for loop inside of build_recommended /// runs at least once. /// The first branch adds coverage on the for loop and the second branch ensures that the first @@ -487,6 +499,7 @@ mod test { "message": "This version of payjoin is not supported." }) .to_string(); + match ctx.process_response(known_json_error.as_bytes()) { Err(ResponseError::WellKnown(WellKnownError { code: ErrorCode::VersionUnsupported, @@ -501,6 +514,7 @@ mod test { "message": "This version of payjoin is not supported." }) .to_string(); + match ctx.process_response(invalid_json_error.as_bytes()) { Err(ResponseError::Validation(_)) => (), _ => panic!("Expected unrecognized JSON error"), @@ -510,6 +524,7 @@ mod test { #[test] fn process_response_valid() { let ctx = create_v1_context(); + let response = ctx.process_response(PAYJOIN_PROPOSAL.as_bytes()); assert!(response.is_ok()) } @@ -517,6 +532,7 @@ mod test { #[test] fn process_response_invalid_psbt() { let ctx = create_v1_context(); + let response = ctx.process_response(INVALID_PSBT.as_bytes()); match response { Ok(_) => panic!("Invalid PSBT should have caused an error"), @@ -536,12 +552,12 @@ mod test { fn process_response_invalid_utf8() { // A PSBT expects an exact match so padding with null bytes for the from_str method is // invalid - let mut invalid_utf8_padding = PAYJOIN_PROPOSAL.as_bytes().to_vec(); - invalid_utf8_padding - .extend(std::iter::repeat_n(0x00, MAX_CONTENT_LENGTH - invalid_utf8_padding.len())); + let mut invalid_utf8 = PAYJOIN_PROPOSAL.as_bytes().to_vec(); + invalid_utf8.extend(std::iter::repeat_n(0x00, MAX_CONTENT_LENGTH - invalid_utf8.len())); let ctx = create_v1_context(); - let response = ctx.process_response(&invalid_utf8_padding); + + let response = ctx.process_response(&invalid_utf8); match response { Ok(_) => panic!("Invalid UTF-8 should have caused an error"), Err(error) => match error { @@ -555,35 +571,4 @@ mod test { }, } } - - #[test] - fn process_response_invalid_buffer_len() { - let mut data = PAYJOIN_PROPOSAL.as_bytes().to_vec(); - data.extend(std::iter::repeat_n(0, MAX_CONTENT_LENGTH + 1)); - - let ctx = create_v1_context(); - let response = ctx.process_response(&data); - match response { - Ok(_) => panic!("Invalid buffer length should have caused an error"), - Err(error) => match error { - ResponseError::Validation(e) => { - assert_eq!( - e.to_string(), - ValidationError::from(InternalValidationError::ContentTooLarge).to_string() - ); - } - _ => panic!("Unexpected error type"), - }, - } - } - - #[test] - fn test_max_content_length() { - assert_eq!(MAX_CONTENT_LENGTH, 4_000_000 * 4 / 3); - } - - #[test] - fn test_non_witness_input_weight_const() { - assert_eq!(NON_WITNESS_INPUT_WEIGHT, bitcoin::Weight::from_wu(160)); - } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 69cf0532b..983e42661 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -924,7 +924,8 @@ mod integration { // ********************** // Inside the Sender: // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?; + let response_body = response.as_bytes(); + let checked_payjoin_proposal_psbt = ctx.process_response(response_body)?; let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; sender.send_raw_transaction(&payjoin_tx)?; @@ -1010,7 +1011,8 @@ mod integration { // ********************** // Inside the Sender: // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(response.as_bytes())?; + let response_body = response.as_bytes(); + let checked_payjoin_proposal_psbt = ctx.process_response(response_body)?; let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; sender.send_raw_transaction(&payjoin_tx)?;