diff --git a/Cargo.lock b/Cargo.lock index 049a99ce..deed2483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,7 +141,7 @@ checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -969,7 +969,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -1717,6 +1717,7 @@ dependencies = [ "rand", "rustls", "serde", + "serde_json", "testcontainers", "testcontainers-modules", "tokio", @@ -1767,9 +1768,9 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3163d2912b7c3b52d651a055f2c7eec9ba5cd22d26ef75b8dd3a59980b185923" +checksum = "1b8fcc794035347fb64beda2d3b462595dd2753e3f268d89c5aae77e8cf2c310" dependencies = [ "base64 0.21.5", "serde", @@ -1827,7 +1828,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -2305,7 +2306,7 @@ checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -2708,9 +2709,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.39" +version = "2.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "13fa70a4ee923979ffb522cacce59d34421ebdea5625e1073c4326ef9d2dd42e" dependencies = [ "proc-macro2", "quote", @@ -2799,7 +2800,7 @@ checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -2883,7 +2884,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -2956,7 +2957,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -3175,7 +3176,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", "wasm-bindgen-shared", ] @@ -3197,7 +3198,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3462,7 +3463,7 @@ checksum = "be912bf68235a88fbefd1b73415cb218405958d1655b2ece9035a19920bdf6ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] @@ -3482,7 +3483,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.40", ] [[package]] diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 9e73fa22..7500575d 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -30,6 +30,7 @@ bhttp = { version = "0.4.0", optional = true } rand = { version = "0.8.4", optional = true } serde = { version = "1.0.186", default-features = false, optional = true } url = "2.2.2" +serde_json = "1.0.108" [dev-dependencies] bitcoind = { version = "0.31.1", features = ["0_21_2"] } @@ -41,4 +42,4 @@ tokio = { version = "1.12.0", features = ["full"] } ureq = "2.8.0" [package.metadata.docs.rs] -features = ["send", "receive", "base64"] \ No newline at end of file +features = ["send", "receive", "base64"] diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 6e70a822..c6b85d4c 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -1,4 +1,5 @@ -use std::fmt; +use std::fmt::{self, Display}; +use std::str::FromStr; use bitcoin::locktime::absolute::LockTime; use bitcoin::Sequence; @@ -16,7 +17,7 @@ pub struct ValidationError { #[derive(Debug)] pub(crate) enum InternalValidationError { - PsbtParse(bitcoin::psbt::PsbtParseError), + Parse, Io(std::io::Error), InvalidInputType(InputTypeError), InvalidProposedInput(crate::psbt::PrevTxOutError), @@ -74,7 +75,7 @@ impl fmt::Display for ValidationError { use InternalValidationError::*; match &self.internal { - PsbtParse(e) => write!(f, "couldn't decode PSBT: {}", e), + Parse => write!(f, "couldn't decode as PSBT or JSON",), Io(e) => write!(f, "couldn't read PSBT: {}", e), InvalidInputType(e) => write!(f, "invalid transaction input type: {}", e), InvalidProposedInput(e) => write!(f, "invalid proposed transaction input: {}", e), @@ -115,7 +116,7 @@ impl std::error::Error for ValidationError { use InternalValidationError::*; match &self.internal { - PsbtParse(error) => Some(error), + Parse => None, Io(error) => Some(error), InvalidInputType(error) => Some(error), InvalidProposedInput(error) => Some(error), @@ -235,3 +236,220 @@ impl std::error::Error for CreateRequestError { impl From for CreateRequestError { fn from(value: InternalCreateRequestError) -> Self { CreateRequestError(value) } } + +#[derive(Debug, Clone)] +struct RawResponse { + error_code: String, + message: String, +} + +impl RawResponse { + fn from_json(json: serde_json::Value) -> Result { + let error_code = json + .as_object() + .and_then(|v| v.get("errorCode")) + .and_then(|v| v.as_str()) + .ok_or(InternalValidationError::Parse)?; + let message = json + .as_object() + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .ok_or(InternalValidationError::Parse)?; + Ok(Self { error_code: error_code.to_string(), message: message.to_string() }) + } + fn from_str(response: &str) -> Result { + Self::from_json(serde_json::Value::from_str(response).map_err(|e| { + log::debug!("Error response must have valid structure, {}", e); + InternalValidationError::Parse + })?) + } +} + +/// Represent an error returned by the receiver. +pub enum ResponseError { + /// `WellKnown` errors following the BIP78 spec + /// https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#user-content-Receivers_well_known_errors + /// These errors are displayed to end users. + /// + /// The `WellKnownError` represents `errorCode` and `message`. + /// The `String` is a custom message that can be used for debug logs. + WellKnown(WellKnownError, String), + /// `Unrecognized` errors are errors that are not well known and are only displayed in debug logs. + /// They are not displayed to end users. + /// + /// The first `String` is `errorCode` + /// The second `String` is `message`. + Unrecognized(String, String), + /// `Validation` errors are errors that are caused by malformed responses. + /// They are only displayed in debug logs. + Validation(ValidationError), +} + +impl ResponseError { + pub fn from_json(json: serde_json::Value) -> Self { + match RawResponse::from_json(json) { + Ok(r) => r.into(), + Err(e) => e.into(), + } + } + pub fn from_str(response: &str) -> Self { + match RawResponse::from_str(response) { + Ok(r) => r.into(), + Err(e) => e.into(), + } + } +} + +impl std::error::Error for ResponseError {} + +impl From for ResponseError { + fn from(value: InternalValidationError) -> Self { + Self::Validation(ValidationError { internal: value }) + } +} + +// It is imperative to carefully display pre-defined messages to end users and the details in debug. +impl Display for ResponseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::WellKnown(e, _) => e.fmt(f), + // Don't display unknowns to end users, only debug logs + Self::Unrecognized(_, _) => write!(f, "The receiver sent an unrecognized error."), + Self::Validation(e) => write!(f, "The receiver sent an invalid response: {}", e), + } + } +} + +impl fmt::Debug for ResponseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::WellKnown(e, msg) => write!( + f, + r#"Well known error: {{ "errorCode": "{}", + "message": "{}" }}"#, + e, msg + ), + Self::Unrecognized(code, msg) => write!( + f, + r#"Unrecognized error: {{ "errorCode": "{}", "message": "{}" }}"#, + code, msg + ), + Self::Validation(e) => write!(f, "Validation({:?})", e), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WellKnownError { + Unavailable, + NotEnoughMoney, + VersionUnsupported, + OriginalPsbtRejected, +} + +impl FromStr for WellKnownError { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "unavailable" => Ok(WellKnownError::Unavailable), + "not-enough-money" => Ok(WellKnownError::NotEnoughMoney), + "version-unsupported" => Ok(WellKnownError::VersionUnsupported), + "original-psbt-rejected" => Ok(WellKnownError::OriginalPsbtRejected), + _ => Err(()), + } + } +} + +impl Display for WellKnownError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Unavailable => write!(f, "The payjoin endpoint is not available for now."), + Self::NotEnoughMoney => write!(f, "The receiver added some inputs but could not bump the fee of the payjoin proposal."), + Self::VersionUnsupported => write!(f, "This version of payjoin is not supported."), + Self::OriginalPsbtRejected => write!(f, "The receiver rejected the original PSBT."), + } + } +} + +impl From for ResponseError { + fn from(value: RawResponse) -> Self { + let RawResponse { error_code, message } = value; + match WellKnownError::from_str(&error_code) { + Ok(e) => ResponseError::WellKnown(e, message), + Err(_) => ResponseError::Unrecognized(error_code, message), + } + } +} + +#[cfg(test)] +mod tests { + use bitcoind::bitcoincore_rpc::jsonrpc::serde_json::json; + + use super::*; + + #[test] + fn test_parse_json() { + let known_json_error = json!({ + "errorCode": "version-unsupported", + "message": "This version of payjoin is not supported." + }); + match RawResponse::from_json(known_json_error.clone()) { + Ok(r) => match r.into() { + ResponseError::WellKnown(e, _) => assert_eq!(e, WellKnownError::VersionUnsupported), + _ => panic!("Error parsing json: {}", known_json_error), + }, + Err(e) => panic!("Error parsing json: {}", e), + } + let known_str_error = "{\"errorCode\":\"version-unsupported\", + \"message\":\"This version of payjoin is not supported.\"}"; + match RawResponse::from_str(known_str_error) { + Ok(r) => match r.into() { + ResponseError::WellKnown(e, _) => assert_eq!(e, WellKnownError::VersionUnsupported), + _ => panic!("Error parsing json: {}", known_str_error), + }, + Err(e) => panic!("Error parsing json: {}", e), + } + let unrecognized_json_error = json!({ + "errorCode": "random", + "message": "This version of payjoin is not supported." + }); + match RawResponse::from_json(unrecognized_json_error.clone()) { + Ok(r) => match r.into() { + ResponseError::Unrecognized(code, msg) => { + assert_eq!(code, "random"); + assert_eq!(msg, "This version of payjoin is not supported."); + } + _ => panic!("Error parsing json: {}", unrecognized_json_error), + }, + Err(e) => panic!("Error parsing json: {}", e), + }; + let invalid_json_error = json!({ + "err": "random", + "message": "This version of payjoin is not supported." + }); + match RawResponse::from_json(invalid_json_error.clone()) { + Ok(_) => { + panic!("Error parsing json: {}", invalid_json_error); + } + Err(e) => match e { + ResponseError::Validation(_) => {} + _ => panic!("Error parsing json: {}", invalid_json_error), + }, + }; + assert_eq!( + ResponseError::from_str(known_str_error).to_string(), + WellKnownError::VersionUnsupported.to_string() + ); + let unrecognized_error = "{\"errorCode\":\"random\", + \"message\":\"This version of payjoin is not supported.\"}"; + assert_eq!( + ResponseError::from_str(unrecognized_error).to_string(), + ResponseError::Unrecognized( + "random".to_string(), + "This version of payjoin is not supported.".to_string() + ) + .to_string() + ); + } +} diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 735f4c02..be0bd749 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -140,7 +140,7 @@ use std::str::FromStr; use bitcoin::address::NetworkChecked; use bitcoin::psbt::Psbt; use bitcoin::{FeeRate, Script, ScriptBuf, Sequence, TxOut, Weight}; -pub use error::{CreateRequestError, ValidationError}; +pub use error::{CreateRequestError, ResponseError, ValidationError}; pub(crate) use error::{InternalCreateRequestError, InternalValidationError}; use url::Url; @@ -529,12 +529,11 @@ impl ContextV1 { pub fn process_response( self, response: &mut impl std::io::Read, - ) -> Result { + ) -> Result { let mut res_str = String::new(); response.read_to_string(&mut res_str).map_err(InternalValidationError::Io)?; - let proposal = Psbt::from_str(&res_str).map_err(InternalValidationError::PsbtParse)?; - - // process in non-generic function + let proposal = + Psbt::from_str(&res_str).or_else(|_| Err(ResponseError::from_str(&res_str)))?; self.process_proposal(proposal).map(Into::into).map_err(Into::into) } @@ -935,21 +934,20 @@ fn serialize_url( #[cfg(test)] mod tests { - #[test] - fn official_vectors() { + use crate::send::error::WellKnownError; + + const ORIGINAL_PSBT: &str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + + const ORIGINAL_PROPOSAL: &str = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcBBBYAFMeKRXJTVYKNVlgHTdUmDV/LaYUwIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUIgICygvBWB5prpfx61y1HDAwo37kYP3YRJBvAjtunBAur3wYSFzWUDEAAIABAACAAAAAgAEAAAABAAAAAAA="; + + fn create_ctx() -> super::ContextV1 { use std::str::FromStr; use bitcoin::psbt::Psbt; use bitcoin::FeeRate; use crate::input_type::{InputType, SegWitV0Type}; - use crate::psbt::PsbtExt; - - let original_psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; - - let proposal = "cHNidP8BAJwCAAAAAo8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////jye60aAl3JgZdaIERvjkeh72VYZuTGH/ps2I4l0IO4MBAAAAAP7///8CJpW4BQAAAAAXqRQd6EnwadJ0FQ46/q6NcutaawlEMIcACT0AAAAAABepFHdAltvPSGdDwi9DR+m0af6+i2d6h9MAAAAAAQEgqBvXBQAAAAAXqRTeTh6QYcpZE1sDWtXm1HmQRUNU0IcBBBYAFMeKRXJTVYKNVlgHTdUmDV/LaYUwIgYDFZrAGqDVh1TEtNi300ntHt/PCzYrT2tVEGcjooWPhRYYSFzWUDEAAIABAACAAAAAgAEAAAAAAAAAAAEBIICEHgAAAAAAF6kUyPLL+cphRyyI5GTUazV0hF2R2NWHAQcXFgAUX4BmVeWSTJIEwtUb5TlPS/ntohABCGsCRzBEAiBnu3tA3yWlT0WBClsXXS9j69Bt+waCs9JcjWtNjtv7VgIge2VYAaBeLPDB6HGFlpqOENXMldsJezF9Gs5amvDQRDQBIQJl1jz1tBt8hNx2owTm+4Du4isx0pmdKNMNIjjaMHFfrQABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUIgICygvBWB5prpfx61y1HDAwo37kYP3YRJBvAjtunBAur3wYSFzWUDEAAIABAACAAAAAgAEAAAABAAAAAAA="; - - let original_psbt = Psbt::from_str(original_psbt).unwrap(); + let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap(); eprintln!("original: {:#?}", original_psbt); let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone(); let sequence = original_psbt.unsigned_tx.input[0].sequence; @@ -962,7 +960,21 @@ mod tests { input_type: InputType::SegWitV0 { ty: SegWitV0Type::Pubkey, nested: true }, sequence, }; - let mut proposal = Psbt::from_str(proposal).unwrap(); + ctx + } + + #[test] + fn official_vectors() { + use std::str::FromStr; + + use bitcoin::psbt::Psbt; + + use crate::psbt::PsbtExt; + + let ctx = create_ctx(); + let original_psbt = Psbt::from_str(ORIGINAL_PSBT).unwrap(); + eprintln!("original: {:#?}", original_psbt); + let mut proposal = Psbt::from_str(ORIGINAL_PROPOSAL).unwrap(); eprintln!("proposal: {:#?}", proposal); for output in proposal.outputs_mut() { output.bip32_derivation.clear(); @@ -973,4 +985,28 @@ mod tests { proposal.inputs_mut()[0].witness_utxo = None; ctx.process_proposal(proposal).unwrap(); } + + #[test] + fn handle_known_errors() { + let ctx = create_ctx(); + let known_json_error = serde_json::json!({ + "errorCode": "version-unsupported", + "message": "This version of payjoin is not supported." + }) + .to_string(); + assert_eq!( + ctx.process_response(&mut known_json_error.as_bytes()).unwrap_err().to_string(), + WellKnownError::VersionUnsupported.to_string() + ); + let ctx = create_ctx(); + let invalid_json_error = serde_json::json!({ + "err": "random", + "message": "This version of payjoin is not supported." + }) + .to_string(); + assert_eq!( + ctx.process_response(&mut invalid_json_error.as_bytes()).unwrap_err().to_string(), + "The receiver sent an invalid response: couldn't decode as PSBT or JSON" + ) + } }