diff --git a/Cargo.lock b/Cargo.lock index ecaa4bb9..0a7f2354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1612,6 +1612,8 @@ dependencies = [ "env_logger", "log", "rand", + "serde", + "serde_json", "url", ] diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index ad597a43..b336e867 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] +v1 = ["payjoin/send", "payjoin/receive"] native-tls-vendored = ["reqwest/native-tls-vendored"] local-https = ["rcgen", "rouille/ssl"] v2 = ["payjoin/v2", "tokio/full", "tokio-tungstenite", "futures-util/sink", "futures-util/std" ] diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 676e870e..4bc739bf 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -207,12 +207,13 @@ impl App { log::debug!("ws parsed"); let (mut write, mut read) = stream.split(); // enroll receiver + log::debug!("Generating ephemeral keypair"); let enroll_string = format!("{} {}", payjoin::v2::RECEIVE, pubkey_base64); write.send(Message::binary(enroll_string.as_bytes())).await?; log::debug!("Enrolled receiver, awaiting request"); let buffer = read.next().await.unwrap()?; log::debug!("Received request"); - let proposal = UncheckedProposal::from_base64(&buffer.into_data()) + let proposal = UncheckedProposal::from_streamed(&buffer.into_data()) .map_err(|e| anyhow!("Failed to parse into UncheckedProposal {}", e))?; let payjoin_psbt = self .process_proposal(proposal) diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 6c6f1aa9..7eac8df0 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -15,13 +15,15 @@ edition = "2018" [features] send = [] receive = ["rand"] -v2 = [] +v2 = ["serde", "serde_json"] [dependencies] bitcoin = { version = "0.30.0", features = ["base64"] } bip21 = "0.3.1" log = { version = "0.4.14"} rand = { version = "0.8.4", optional = true } +serde = { version = "1.0", optional = true } +serde_json = { version = "1.0", optional = true } url = "2.2.2" [dev-dependencies] diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index 91605752..522c184e 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -30,6 +30,8 @@ pub mod send; #[cfg(any(feature = "send", feature = "receive"))] pub(crate) mod input_type; #[cfg(any(feature = "send", feature = "receive"))] +pub(crate) mod optional_parameters; +#[cfg(any(feature = "send", feature = "receive"))] pub(crate) mod psbt; mod uri; #[cfg(any(feature = "send", feature = "receive"))] diff --git a/payjoin/src/optional_parameters.rs b/payjoin/src/optional_parameters.rs new file mode 100644 index 00000000..b94e633c --- /dev/null +++ b/payjoin/src/optional_parameters.rs @@ -0,0 +1,257 @@ +use std::borrow::Borrow; +use std::fmt; + +use bitcoin::{Amount, FeeRate}; +use log::warn; +use serde::de::{Deserializer, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize, Serializer}; + +#[derive(Debug)] +#[cfg_attr(feature = "v2", derive(Deserialize, Serialize))] +pub(crate) struct Params { + // version + #[cfg_attr( + feature = "v2", + serde(skip_serializing_if = "skip_if_default_v", default = "default_v") + )] + pub v: usize, + + // disableoutputsubstitution + #[cfg_attr( + feature = "v2", + serde(skip_serializing_if = "skip_if_false", default = "default_output_substitution") + )] + pub disable_output_substitution: bool, + + // maxadditionalfeecontribution, additionalfeeoutputindex + #[cfg_attr( + feature = "v2", + serde( + deserialize_with = "deserialize_additional_fee_contribution", + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_additional_fee_contribution" + ) + )] + pub additional_fee_contribution: Option<(Amount, usize)>, + + // minfeerate + #[cfg_attr( + feature = "v2", + serde( + deserialize_with = "from_sat_per_vb", + skip_serializing_if = "skip_if_zero_rate", + default = "default_min_feerate" + ) + )] + pub min_feerate: FeeRate, +} + +impl Default for Params { + fn default() -> Self { + Params { + v: 1, + disable_output_substitution: false, + additional_fee_contribution: None, + min_feerate: FeeRate::ZERO, + } + } +} + +impl Params { + #[cfg(feature = "receive")] + pub fn from_query_pairs(pairs: I) -> Result + where + I: Iterator, + K: Borrow + Into, + V: Borrow + Into, + { + let mut params = Params::default(); + + let mut additional_fee_output_index = None; + let mut max_additional_fee_contribution = None; + + for (k, v) in pairs { + match (k.borrow(), v.borrow()) { + ("v", v) => + if v != "1" { + return Err(Error::UnknownVersion); + }, + ("additionalfeeoutputindex", index) => + additional_fee_output_index = match index.parse::() { + Ok(index) => Some(index), + Err(_error) => { + warn!( + "bad `additionalfeeoutputindex` query value '{}': {}", + index, _error + ); + None + } + }, + ("maxadditionalfeecontribution", fee) => + max_additional_fee_contribution = + match bitcoin::Amount::from_str_in(fee, bitcoin::Denomination::Satoshi) { + Ok(contribution) => Some(contribution), + Err(_error) => { + warn!( + "bad `maxadditionalfeecontribution` query value '{}': {}", + fee, _error + ); + None + } + }, + ("minfeerate", feerate) => + params.min_feerate = match feerate.parse::() { + Ok(fee_rate_sat_per_vb) => { + // TODO Parse with serde when rust-bitcoin supports it + let fee_rate_sat_per_kwu = fee_rate_sat_per_vb * 250.0_f32; + // since it's a minnimum, we want to round up + FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64) + } + Err(e) => return Err(Error::FeeRate(e.to_string())), + }, + ("disableoutputsubstitution", v) => + params.disable_output_substitution = v == "true", + _ => (), + } + } + + match (max_additional_fee_contribution, additional_fee_output_index) { + (Some(amount), Some(index)) => + params.additional_fee_contribution = Some((amount, index)), + (Some(_), None) | (None, Some(_)) => { + warn!("only one additional-fee parameter specified: {:?}", params); + } + _ => (), + } + + log::debug!("parsed optional parameters: {:?}", params); + Ok(params) + } +} + +fn deserialize_additional_fee_contribution<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct AdditionalFeeContributionVisitor; + + impl<'de> Visitor<'de> for AdditionalFeeContributionVisitor { + type Value = Option<(bitcoin::Amount, usize)>; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct params") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut additional_fee_output_index: Option = None; + let mut max_additional_fee_contribution: Option = None; + + while let Some(key) = map.next_key()? { + match key { + "additional_fee_output_index" => { + additional_fee_output_index = Some(map.next_value()?); + } + "max_additional_fee_contribution" => { + max_additional_fee_contribution = + Some(bitcoin::Amount::from_sat(map.next_value()?)); + } + _ => { + // ignore other fields + } + } + } + + let additional_fee_contribution = + match (max_additional_fee_contribution, additional_fee_output_index) { + (Some(amount), Some(index)) => Some((amount, index)), + (Some(_), None) | (None, Some(_)) => { + warn!( + "only one additional-fee parameter specified: {:?}, {:?}", + max_additional_fee_contribution, additional_fee_output_index + ); + None + } + _ => None, + }; + Ok(additional_fee_contribution) + } + } + + deserializer.deserialize_map(AdditionalFeeContributionVisitor) +} + +fn default_v() -> usize { 2 } + +fn default_output_substitution() -> bool { false } + +fn default_min_feerate() -> FeeRate { FeeRate::ZERO } + +// Function to determine whether to skip serializing a usize if it is 2 (the default) +fn skip_if_default_v(v: &usize) -> bool { *v == 2 } + +// Function to determine whether to skip serializing a bool if it is false (the default) +fn skip_if_false(b: &bool) -> bool { !(*b) } + +// Function to determine whether to skip serializing a FeeRate if it is ZERO (the default) +fn skip_if_zero_rate(rate: &FeeRate) -> bool { + *rate == FeeRate::ZERO // replace with your actual comparison logic +} + +fn from_sat_per_vb<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let fee_rate_sat_per_vb = f32::deserialize(deserializer)?; + Ok(FeeRate::from_sat_per_kwu((fee_rate_sat_per_vb * 250.0_f32) as u64)) +} + +fn serialize_amount(amount: &Amount, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_u64(amount.to_sat()) +} + +fn serialize_additional_fee_contribution( + additional_fee_contribution: &Option<(Amount, usize)>, + serializer: S, +) -> Result +where + S: Serializer, +{ + let mut map = serializer.serialize_map(None)?; + if let Some((amount, index)) = additional_fee_contribution { + map.serialize_entry("additional_fee_output_index", index)?; + map.serialize_entry("max_additional_fee_contribution", &amount.to_sat())?; + } + map.end() +} + +#[derive(Debug)] +pub(crate) enum Error { + UnknownVersion, + FeeRate(String), + #[cfg(feature = "v2")] + Json(serde_json::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::UnknownVersion => write!(f, "unknown version"), + Error::FeeRate(_) => write!(f, "could not parse feerate"), + #[cfg(feature = "v2")] + Error::Json(e) => write!(f, "could not parse json: {}", e), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } +} diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index ffc91360..d9dbc9e3 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -47,7 +47,7 @@ pub(crate) enum InternalRequestError { InvalidContentType(String), InvalidContentLength(std::num::ParseIntError), ContentLengthTooLarge(u64), - SenderParams(super::optional_parameters::Error), + SenderParams(crate::optional_parameters::Error), /// The raw PSBT fails bip78-specific validation. InconsistentPsbt(crate::psbt::InconsistentPsbt), /// The prevtxout is missing @@ -65,6 +65,9 @@ pub(crate) enum InternalRequestError { /// Original PSBT input has been seen before. Only automatic receivers, aka "interactive" in the spec /// look out for these to prevent probing attacks. InputSeen(bitcoin::OutPoint), + /// Serde deserialization failed + #[cfg(feature = "v2")] + Json(serde_json::Error), } impl From for RequestError { @@ -96,7 +99,7 @@ impl fmt::Display for RequestError { &format!("Content length too large: {}.", length), ), InternalRequestError::SenderParams(e) => match e { - super::optional_parameters::Error::UnknownVersion => write_error( + crate::optional_parameters::Error::UnknownVersion => write_error( f, "version-unsupported", "This version of payjoin is not supported.", @@ -125,6 +128,8 @@ impl fmt::Display for RequestError { write_error(f, "original-psbt-rejected", &format!("Input Type Error: {}.", e)), InternalRequestError::InputSeen(_) => write_error(f, "original-psbt-rejected", "The receiver rejected the original PSBT."), + #[cfg(feature = "v2")] + InternalRequestError::Json(e) => write_error(f, "json-error", e), } } } diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index b8b57a6b..ddebb41e 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -273,15 +273,14 @@ use bitcoin::psbt::Psbt; use bitcoin::{Amount, FeeRate, OutPoint, Script, TxOut}; mod error; -mod optional_parameters; pub use error::{Error, RequestError, SelectionError}; use error::{InternalRequestError, InternalSelectionError}; -use optional_parameters::Params; use rand::seq::SliceRandom; use rand::Rng; use crate::input_type::InputType; +use crate::optional_parameters::Params; use crate::psbt::PsbtExt; pub trait Headers { @@ -297,7 +296,9 @@ pub trait Headers { /// transaction with get_transaction_to_schedule_broadcast() and schedule, followed by checking /// that the transaction can be broadcast with check_can_broadcast. Otherwise it is safe to /// call assume_interactive_receive to proceed with validation. +#[cfg_attr(feature = "v2", derive(serde::Deserialize))] pub struct UncheckedProposal { + #[cfg_attr(feature = "v2", serde(deserialize_with = "deserialize_psbt"))] psbt: Psbt, params: Params, } @@ -326,24 +327,34 @@ pub struct MaybeInputsSeen { params: Params, } -impl UncheckedProposal { - #[cfg(feature = "v2")] - pub fn from_base64(buf: &[u8]) -> Result { - let base64 = bitcoin::base64::decode(buf).map_err(InternalRequestError::Base64)?; - let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; - - let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; - log::debug!("Received original psbt: {:?}", psbt); - - // TODO accept parameters - // let pairs = url::form_urlencoded::parse(query.as_bytes()); - // let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; - // log::debug!("Received request with params: {:?}", params); +pub(crate) fn deserialize_psbt<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let buf = ::deserialize(deserializer)?; + let base64 = bitcoin::base64::decode(buf.as_bytes()) + .map_err(|e| D::Error::custom(format!("Base64 decoding error: {:?}", e)))?; + let unchecked_psbt = Psbt::deserialize(&base64) + .map_err(|e| D::Error::custom(format!("Psbt deserialization error: {:?}", e)))?; + Ok(unchecked_psbt) +} - Ok(UncheckedProposal { psbt, params: Params::default() }) +#[cfg(feature = "v2")] +impl UncheckedProposal { + pub fn from_streamed(streamed: &[u8]) -> Result { + let mut proposal = serde_json::from_slice::(streamed) + .map_err(InternalRequestError::Json)?; + proposal.psbt = proposal.psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", proposal.psbt); + log::debug!("Received request with params: {:?}", proposal.params); + + Ok(proposal) } +} - #[cfg(not(feature = "v2"))] +impl UncheckedProposal { pub fn from_request( mut body: impl std::io::Read, query: &str, diff --git a/payjoin/src/receive/optional_parameters.rs b/payjoin/src/receive/optional_parameters.rs deleted file mode 100644 index 711d84f8..00000000 --- a/payjoin/src/receive/optional_parameters.rs +++ /dev/null @@ -1,118 +0,0 @@ -use std::borrow::Borrow; -use std::fmt; - -use bitcoin::FeeRate; -use log::warn; - -#[derive(Debug)] -pub(crate) struct Params { - // version - // v: usize, - // disableoutputsubstitution - pub disable_output_substitution: bool, - // maxadditionalfeecontribution, additionalfeeoutputindex - pub additional_fee_contribution: Option<(bitcoin::Amount, usize)>, - // minfeerate - pub min_feerate: FeeRate, -} - -impl Default for Params { - fn default() -> Self { - Params { - disable_output_substitution: false, - additional_fee_contribution: None, - min_feerate: FeeRate::ZERO, - } - } -} - -impl Params { - #[cfg(feature = "receive")] - pub fn from_query_pairs(pairs: I) -> Result - where - I: Iterator, - K: Borrow + Into, - V: Borrow + Into, - { - let mut params = Params::default(); - - let mut additional_fee_output_index = None; - let mut max_additional_fee_contribution = None; - - for (k, v) in pairs { - match (k.borrow(), v.borrow()) { - ("v", v) => - if v != "1" { - return Err(Error::UnknownVersion); - }, - ("additionalfeeoutputindex", index) => - additional_fee_output_index = match index.parse::() { - Ok(index) => Some(index), - Err(_error) => { - warn!( - "bad `additionalfeeoutputindex` query value '{}': {}", - index, _error - ); - None - } - }, - ("maxadditionalfeecontribution", fee) => - max_additional_fee_contribution = - match bitcoin::Amount::from_str_in(fee, bitcoin::Denomination::Satoshi) { - Ok(contribution) => Some(contribution), - Err(_error) => { - warn!( - "bad `maxadditionalfeecontribution` query value '{}': {}", - fee, _error - ); - None - } - }, - ("minfeerate", feerate) => - params.min_feerate = match feerate.parse::() { - Ok(fee_rate_sat_per_vb) => { - // TODO Parse with serde when rust-bitcoin supports it - let fee_rate_sat_per_kwu = fee_rate_sat_per_vb * 250.0_f32; - // since it's a minnimum, we want to round up - FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64) - } - Err(e) => return Err(Error::FeeRate(e.to_string())), - }, - ("disableoutputsubstitution", v) => - params.disable_output_substitution = v == "true", - _ => (), - } - } - - match (max_additional_fee_contribution, additional_fee_output_index) { - (Some(amount), Some(index)) => - params.additional_fee_contribution = Some((amount, index)), - (Some(_), None) | (None, Some(_)) => { - warn!("only one additional-fee parameter specified: {:?}", params); - } - _ => (), - } - - log::debug!("parsed optional parameters: {:?}", params); - Ok(params) - } -} - -#[derive(Debug)] -pub(crate) enum Error { - UnknownVersion, - FeeRate(String), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Error::UnknownVersion => write!(f, "unknown version"), - Error::FeeRate(_) => write!(f, "could not parse feerate"), - } - } -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } -} diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 411ac002..cb465c15 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -710,6 +710,30 @@ fn determine_fee_contribution( }) } +#[cfg(feature = "v2")] +fn serialize_v2_body( + psbt: &Psbt, + disable_output_substitution: bool, + fee_contribution: Option<(bitcoin::Amount, usize)>, + min_feerate: FeeRate, +) -> Vec { + use serde_json::json; + + let params = crate::optional_parameters::Params { + v: 2, + disable_output_substitution, + additional_fee_contribution: fee_contribution, + min_feerate, + }; + + let body = json!({ + "psbt": serialize_psbt(psbt), + "params": serde_json::to_value(params).unwrap(), + }); + + serde_json::to_vec(&body).unwrap() +} + fn serialize_url( endpoint: String, disable_output_substitution: bool, @@ -734,11 +758,19 @@ fn serialize_url( Ok(url) } -fn serialize_psbt(psbt: &Psbt) -> Vec { +fn serialize_psbt(psbt: &Psbt) -> String { let bytes = psbt.serialize(); - bitcoin::base64::encode(bytes).into_bytes() + bitcoin::base64::encode(bytes) +} + +fn to_feerate(feerate: f32) -> FeeRate { FeeRate::from_sat_per_kwu((feerate * 250.0_f32) as u64) } + +fn serialize_minfeerate(min_feerate: FeeRate) -> f32 { + min_feerate.to_sat_per_kwu() as f32 / 250.0_f32 } +#[cfg(feature = "send")] +#[cfg(not(feature = "v2"))] pub(crate) fn from_psbt_and_uri( mut psbt: Psbt, uri: crate::uri::PjUri<'_>, @@ -780,6 +812,48 @@ pub(crate) fn from_psbt_and_uri( )) } +#[cfg(all(feature = "send", feature = "v2"))] +pub(crate) fn from_psbt_and_uri( + mut psbt: Psbt, + uri: crate::uri::PjUri<'_>, + params: Configuration, +) -> Result<(Request, Context), CreateRequestError> { + log::debug!("v2 confirmed"); + psbt.validate_input_utxos(true).map_err(InternalCreateRequestError::InvalidOriginalInput)?; + let disable_output_substitution = + uri.extras.disable_output_substitution || params.disable_output_substitution; + let payee = uri.address.script_pubkey(); + + check_single_payee(&psbt, &payee, uri.amount)?; + let fee_contribution = determine_fee_contribution(&psbt, &payee, ¶ms)?; + clear_unneeded_fields(&mut psbt); + + let zeroth_input = psbt.input_pairs().next().ok_or(InternalCreateRequestError::NoInputs)?; + + let sequence = zeroth_input.txin.sequence; + let txout = zeroth_input.previous_txout().expect("We already checked this above"); + let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin).unwrap(); + let url = uri.extras._endpoint; + let body = serialize_v2_body( + &psbt, + disable_output_substitution, + fee_contribution, + params.min_fee_rate, + ); + Ok(( + Request { url, body }, + Context { + original_psbt: psbt, + disable_output_substitution, + fee_contribution, + payee, + input_type, + sequence, + min_fee_rate: params.min_fee_rate, + }, + )) +} + #[cfg(test)] mod tests { #[test]