From 0eff567697344103cd6dc4493b967844b1a1b618 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 21 Jan 2025 11:04:40 -0500 Subject: [PATCH 1/5] Move shared `RequestError` to `PayloadError` `bitcoin::psbt::Error` is a type of PayloadError non-exclusive to v1 --- payjoin/src/receive/mod.rs | 1 + payjoin/src/receive/v1/error.rs | 4 ---- payjoin/src/receive/v1/mod.rs | 6 ++++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 24f41da26..b6d7f7497 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -7,6 +7,7 @@ use crate::psbt::{InternalInputPair, InternalPsbtInputError}; mod error; pub(crate) mod optional_parameters; + pub mod v1; #[cfg(feature = "v2")] pub mod v2; diff --git a/payjoin/src/receive/v1/error.rs b/payjoin/src/receive/v1/error.rs index 5b5371699..26251d283 100644 --- a/payjoin/src/receive/v1/error.rs +++ b/payjoin/src/receive/v1/error.rs @@ -16,8 +16,6 @@ pub struct RequestError(InternalRequestError); #[derive(Debug)] pub(crate) enum InternalRequestError { - /// Error parsing or validating the PSBT - Psbt(bitcoin::psbt::Error), /// I/O error while reading the request body Io(std::io::Error), /// A required HTTP header is missing from the request @@ -45,7 +43,6 @@ impl fmt::Display for RequestError { } match &self.0 { - InternalRequestError::Psbt(e) => write_error(f, "psbt-error", e), InternalRequestError::Io(e) => write_error(f, "io-error", e), InternalRequestError::MissingHeader(header) => write_error(f, "missing-header", format!("Missing header: {}", header)), @@ -68,7 +65,6 @@ impl fmt::Display for RequestError { impl error::Error for RequestError { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self.0 { - InternalRequestError::Psbt(e) => Some(e), InternalRequestError::Io(e) => Some(e), InternalRequestError::InvalidContentLength(e) => Some(e), InternalRequestError::MissingHeader(_) => None, diff --git a/payjoin/src/receive/v1/mod.rs b/payjoin/src/receive/v1/mod.rs index 58b23fe72..85fc955f9 100644 --- a/payjoin/src/receive/v1/mod.rs +++ b/payjoin/src/receive/v1/mod.rs @@ -122,8 +122,10 @@ impl UncheckedProposal { self.psbt.clone().extract_tx_unchecked_fee_rate() } - fn psbt_fee_rate(&self) -> Result { - let original_psbt_fee = self.psbt.fee().map_err(InternalRequestError::Psbt)?; + fn psbt_fee_rate(&self) -> Result { + let original_psbt_fee = self.psbt.fee().map_err(|e| { + InternalPayloadError::ParsePsbt(bitcoin::psbt::PsbtParseError::PsbtEncoding(e)) + })?; Ok(original_psbt_fee / self.extract_tx_to_schedule_broadcast().weight()) } From c17e2d88a26f633beec68d099d49f15ed31c3312 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 21 Jan 2025 14:29:38 -0500 Subject: [PATCH 2/5] Divide receive::v1 into exclusive and shared mods The exclusive module is used only when v1 is explicitly enabled. Shared components are `pub(crate)` and used when the v2 feature is enabled but not exported. --- contrib/coverage.sh | 3 +- contrib/lint.sh | 3 +- payjoin/Cargo.toml | 12 ++- payjoin/src/lib.rs | 11 ++- payjoin/src/receive/error.rs | 12 +-- payjoin/src/receive/mod.rs | 4 + .../src/receive/v1/{ => exclusive}/error.rs | 10 ++ payjoin/src/receive/v1/exclusive/mod.rs | 65 +++++++++++++ payjoin/src/receive/v1/mod.rs | 97 +++---------------- payjoin/tests/integration.rs | 3 + 10 files changed, 115 insertions(+), 105 deletions(-) rename payjoin/src/receive/v1/{ => exclusive}/error.rs (90%) create mode 100644 payjoin/src/receive/v1/exclusive/mod.rs diff --git a/contrib/coverage.sh b/contrib/coverage.sh index ff090c9f2..573ddfbe5 100755 --- a/contrib/coverage.sh +++ b/contrib/coverage.sh @@ -3,6 +3,5 @@ set -e # https://github.com/taiki-e/cargo-llvm-cov?tab=readme-ov-file#merge-coverages-generated-under-different-test-conditions cargo llvm-cov clean --workspace # remove artifacts that may affect the coverage results -cargo llvm-cov --no-report --no-default-features --features=v1,_danger-local-https -cargo llvm-cov --no-report --no-default-features --features=v2,_danger-local-https,io +cargo llvm-cov --no-report --all-features cargo llvm-cov report --lcov --output-path lcov.info # generate report without tests diff --git a/contrib/lint.sh b/contrib/lint.sh index 9b279da44..00368fb53 100755 --- a/contrib/lint.sh +++ b/contrib/lint.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash set -e -cargo clippy --all-targets --keep-going --no-default-features --features=v1,_danger-local-https -- -D warnings -cargo clippy --all-targets --keep-going --no-default-features --features=v2,_danger-local-https,io -- -D warnings +cargo clippy --all-targets --keep-going --all-features -- -D warnings diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 450b5d824..03a629140 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,15 +18,17 @@ exclude = ["tests"] [features] default = ["v2"] base64 = ["bitcoin/base64"] -v1 = ["bitcoin/rand"] -v2 = ["bitcoin/rand", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "serde", "url/serde" ] +#[doc = "Core features for payjoin state machines"] +_core = ["bitcoin/rand", "serde_json", "url", "bitcoin_uri"] +v1 = ["_core"] +v2 = ["_core", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "serde", "url/serde"] #[doc = "Functions to fetch OHTTP keys via CONNECT proxy using reqwest. Enables `v2` since only `v2` uses OHTTP."] io = ["v2", "reqwest/rustls-tls"] _danger-local-https = ["reqwest/rustls-tls", "rustls"] [dependencies] bitcoin = { version = "0.32.5", features = ["base64"] } -bitcoin_uri = "0.1.0" +bitcoin_uri = { version = "0.1.0", optional = true } hpke = { package = "bitcoin-hpke", version = "0.13.0", optional = true } log = { version = "0.4.14"} http = { version = "1", optional = true } @@ -35,8 +37,8 @@ ohttp = { package = "bitcoin-ohttp", version = "0.6.0", optional = true } serde = { version = "1.0.186", default-features = false, optional = true } reqwest = { version = "0.12", default-features = false, optional = true } rustls = { version = "0.22.4", optional = true } -url = "2.2.2" -serde_json = "1.0.108" +url = { version = "2.2.2", optional = true } +serde_json = { version = "1.0.108", optional = true } [dev-dependencies] bitcoind = { version = "0.36.0", features = ["0_21_2"] } diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index c0ef5527e..875ef1eee 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -17,9 +17,12 @@ //! //! To use this library as a receiver (server, payee), you need to enable `receive` Cargo feature. +#[cfg(feature = "_core")] pub extern crate bitcoin; +#[cfg(feature = "_core")] pub mod receive; +#[cfg(feature = "_core")] pub mod send; #[cfg(feature = "v2")] @@ -35,14 +38,18 @@ pub(crate) mod bech32; #[cfg(feature = "io")] pub mod io; - +#[cfg(feature = "_core")] pub(crate) mod psbt; +#[cfg(feature = "_core")] mod request; +#[cfg(feature = "_core")] pub use request::*; - +#[cfg(feature = "_core")] mod uri; #[cfg(feature = "base64")] pub use bitcoin::base64; +#[cfg(feature = "_core")] pub use uri::{PjParseError, PjUri, Uri, UriExt}; +#[cfg(feature = "_core")] pub use url::{ParseError, Url}; diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index 444ac6969..c60baa82d 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -1,5 +1,6 @@ use std::{error, fmt}; +#[cfg(feature = "v1")] use crate::receive::v1; #[cfg(feature = "v2")] use crate::receive::v2; @@ -58,10 +59,6 @@ impl From for Error { } } -impl From for Error { - fn from(e: v1::InternalRequestError) -> Self { Error::Validation(e.into()) } -} - /// An error that occurs during validation of a payjoin request, encompassing all possible validation /// failures across different protocol versions and stages. /// @@ -72,6 +69,7 @@ pub enum ValidationError { /// Error arising from validation of the original PSBT payload Payload(PayloadError), /// Protocol-specific errors for BIP-78 v1 requests (e.g. HTTP request validation, parameter checks) + #[cfg(feature = "v1")] V1(v1::RequestError), /// Protocol-specific errors for BIP-77 v2 sessions (e.g. session management, OHTTP, HPKE encryption) #[cfg(feature = "v2")] @@ -82,10 +80,6 @@ impl From for ValidationError { fn from(e: InternalPayloadError) -> Self { ValidationError::Payload(e.into()) } } -impl From for ValidationError { - fn from(e: v1::InternalRequestError) -> Self { ValidationError::V1(e.into()) } -} - #[cfg(feature = "v2")] impl From for ValidationError { fn from(e: v2::InternalSessionError) -> Self { ValidationError::V2(e.into()) } @@ -95,6 +89,7 @@ impl fmt::Display for ValidationError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ValidationError::Payload(e) => write!(f, "{}", e), + #[cfg(feature = "v1")] ValidationError::V1(e) => write!(f, "{}", e), #[cfg(feature = "v2")] ValidationError::V2(e) => write!(f, "{}", e), @@ -106,6 +101,7 @@ impl std::error::Error for ValidationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { ValidationError::Payload(e) => Some(e), + #[cfg(feature = "v1")] ValidationError::V1(e) => Some(e), #[cfg(feature = "v2")] ValidationError::V2(e) => Some(e), diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index b6d7f7497..430a8faec 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -8,7 +8,11 @@ use crate::psbt::{InternalInputPair, InternalPsbtInputError}; mod error; pub(crate) mod optional_parameters; +#[cfg(feature = "v1")] pub mod v1; +#[cfg(not(feature = "v1"))] +pub(crate) mod v1; + #[cfg(feature = "v2")] pub mod v2; diff --git a/payjoin/src/receive/v1/error.rs b/payjoin/src/receive/v1/exclusive/error.rs similarity index 90% rename from payjoin/src/receive/v1/error.rs rename to payjoin/src/receive/v1/exclusive/error.rs index 26251d283..7b6e584d0 100644 --- a/payjoin/src/receive/v1/error.rs +++ b/payjoin/src/receive/v1/exclusive/error.rs @@ -1,6 +1,8 @@ use core::fmt; use std::error; +use crate::receive::error::ValidationError; + /// Error that occurs during validation of an incoming v1 payjoin request. /// /// This type provides a stable public API for v1 request validation errors while keeping internal @@ -32,6 +34,14 @@ impl From for RequestError { fn from(value: InternalRequestError) -> Self { RequestError(value) } } +impl From for super::Error { + fn from(e: InternalRequestError) -> Self { super::Error::Validation(e.into()) } +} + +impl From for ValidationError { + fn from(e: InternalRequestError) -> Self { ValidationError::V1(e.into()) } +} + impl fmt::Display for RequestError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn write_error( diff --git a/payjoin/src/receive/v1/exclusive/mod.rs b/payjoin/src/receive/v1/exclusive/mod.rs new file mode 100644 index 000000000..a7305eac7 --- /dev/null +++ b/payjoin/src/receive/v1/exclusive/mod.rs @@ -0,0 +1,65 @@ +use std::str::FromStr; +mod error; +pub(crate) use error::InternalRequestError; +pub use error::RequestError; + +use super::*; +use crate::receive::optional_parameters::Params; + +const SUPPORTED_VERSIONS: &[usize] = &[1]; + +pub trait Headers { + fn get_header(&self, key: &str) -> Option<&str>; +} + +pub fn build_v1_pj_uri<'a>( + address: &bitcoin::Address, + endpoint: &url::Url, + disable_output_substitution: bool, +) -> crate::uri::PjUri<'a> { + let extras = + crate::uri::PayjoinExtras { endpoint: endpoint.clone(), disable_output_substitution }; + bitcoin_uri::Uri::with_extras(address.clone(), extras) +} + +impl UncheckedProposal { + pub fn from_request( + mut body: impl std::io::Read, + query: &str, + headers: impl Headers, + ) -> Result { + let content_type = headers + .get_header("content-type") + .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; + if !content_type.starts_with("text/plain") { + return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); + } + let content_length = headers + .get_header("content-length") + .ok_or(InternalRequestError::MissingHeader("Content-Length"))? + .parse::() + .map_err(InternalRequestError::InvalidContentLength)?; + // 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length + if content_length > 4_000_000 * 4 / 3 { + return Err(InternalRequestError::ContentLengthTooLarge(content_length).into()); + } + + // enforce the limit + let mut buf = vec![0; content_length as usize]; // 4_000_000 * 4 / 3 fits in u32 + body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; + let base64 = String::from_utf8(buf).map_err(InternalPayloadError::Utf8)?; + let unchecked_psbt = Psbt::from_str(&base64).map_err(InternalPayloadError::ParsePsbt)?; + + let psbt = unchecked_psbt.validate().map_err(InternalPayloadError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", psbt); + + let pairs = url::form_urlencoded::parse(query.as_bytes()); + let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS) + .map_err(InternalPayloadError::SenderParams)?; + log::debug!("Received request with params: {:?}", params); + + // TODO check that params are valid for the request's Original PSBT + + Ok(UncheckedProposal { psbt, params }) + } +} diff --git a/payjoin/src/receive/v1/mod.rs b/payjoin/src/receive/v1/mod.rs index 85fc955f9..be2799b39 100644 --- a/payjoin/src/receive/v1/mod.rs +++ b/payjoin/src/receive/v1/mod.rs @@ -25,14 +25,11 @@ //! [reference implementation](https://github.com/payjoin/rust-payjoin/tree/master/payjoin-cli) use std::cmp::{max, min}; -use std::str::FromStr; use bitcoin::psbt::Psbt; use bitcoin::secp256k1::rand::seq::SliceRandom; use bitcoin::secp256k1::rand::{self, Rng}; use bitcoin::{Amount, FeeRate, OutPoint, Script, TxIn, TxOut, Weight}; -pub(crate) use error::InternalRequestError; -pub use error::RequestError; use super::error::{ InputContributionError, InternalInputContributionError, InternalOutputSubstitutionError, @@ -43,23 +40,10 @@ use super::{Error, InputPair, OutputSubstitutionError, SelectionError}; use crate::psbt::PsbtExt; use crate::receive::InternalPayloadError; -mod error; - -const SUPPORTED_VERSIONS: &[usize] = &[1]; - -pub trait Headers { - fn get_header(&self, key: &str) -> Option<&str>; -} - -pub fn build_v1_pj_uri<'a>( - address: &bitcoin::Address, - endpoint: &url::Url, - disable_output_substitution: bool, -) -> crate::uri::PjUri<'a> { - let extras = - crate::uri::PayjoinExtras { endpoint: endpoint.clone(), disable_output_substitution }; - bitcoin_uri::Uri::with_extras(address.clone(), extras) -} +#[cfg(feature = "v1")] +mod exclusive; +#[cfg(feature = "v1")] +pub use exclusive::*; /// The sender's original PSBT and optional parameters /// @@ -77,46 +61,6 @@ pub struct UncheckedProposal { } impl UncheckedProposal { - pub fn from_request( - mut body: impl std::io::Read, - query: &str, - headers: impl Headers, - ) -> Result { - let content_type = headers - .get_header("content-type") - .ok_or(InternalRequestError::MissingHeader("Content-Type"))?; - if !content_type.starts_with("text/plain") { - return Err(InternalRequestError::InvalidContentType(content_type.to_owned()).into()); - } - let content_length = headers - .get_header("content-length") - .ok_or(InternalRequestError::MissingHeader("Content-Length"))? - .parse::() - .map_err(InternalRequestError::InvalidContentLength)?; - // 4M block size limit with base64 encoding overhead => maximum reasonable size of content-length - if content_length > 4_000_000 * 4 / 3 { - return Err(InternalRequestError::ContentLengthTooLarge(content_length).into()); - } - - // enforce the limit - let mut buf = vec![0; content_length as usize]; // 4_000_000 * 4 / 3 fits in u32 - body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; - let base64 = String::from_utf8(buf).map_err(InternalPayloadError::Utf8)?; - let unchecked_psbt = Psbt::from_str(&base64).map_err(InternalPayloadError::ParsePsbt)?; - - let psbt = unchecked_psbt.validate().map_err(InternalPayloadError::InconsistentPsbt)?; - log::debug!("Received original psbt: {:?}", psbt); - - let pairs = url::form_urlencoded::parse(query.as_bytes()); - let params = Params::from_query_pairs(pairs, SUPPORTED_VERSIONS) - .map_err(InternalPayloadError::SenderParams)?; - log::debug!("Received request with params: {:?}", params); - - // TODO check that params are valid for the request's Original PSBT - - Ok(UncheckedProposal { psbt, params }) - } - /// The Sender's Original PSBT transaction pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { self.psbt.clone().extract_tx_unchecked_fee_rate() @@ -890,38 +834,19 @@ pub(crate) mod test { use super::*; - struct MockHeaders { - length: String, - } - - impl MockHeaders { - fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } - } - - impl Headers for MockHeaders { - fn get_header(&self, key: &str) -> Option<&str> { - match key { - "content-length" => Some(&self.length), - "content-type" => Some("text/plain"), - _ => None, - } - } - } - - pub(crate) fn proposal_from_test_vector() -> Result { + pub(crate) fn proposal_from_test_vector( + ) -> Result> { // OriginalPSBT Test Vector from BIP // | InputScriptType | Orginal PSBT Fee rate | maxadditionalfeecontribution | additionalfeeoutputindex| // |-----------------|-----------------------|------------------------------|-------------------------| // | P2SH-P2WPKH | 2 sat/vbyte | 0.00000182 | 0 | let original_psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; - let body = original_psbt.as_bytes(); - let headers = MockHeaders::new(body.len() as u64); - UncheckedProposal::from_request( - body, - "maxadditionalfeecontribution=182&additionalfeeoutputindex=0", - headers, - ) + let pairs = url::form_urlencoded::parse( + "maxadditionalfeecontribution=182&additionalfeeoutputindex=0".as_bytes(), + ); + let params = Params::from_query_pairs(pairs, &[1])?; + Ok(UncheckedProposal { psbt: bitcoin::Psbt::from_str(original_psbt)?, params }) } #[test] diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 37cb6e06f..3125132d1 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1,3 +1,4 @@ +#[cfg(all(feature = "v1", feature = "v2"))] mod integration { use std::collections::HashMap; use std::env; @@ -23,6 +24,7 @@ mod integration { static EXAMPLE_URL: Lazy = Lazy::new(|| Url::parse("https://example.com").expect("Invalid Url")); + #[cfg(feature = "v1")] mod v1 { use log::debug; use payjoin::send::v1::SenderBuilder; @@ -976,6 +978,7 @@ mod integration { } } + #[cfg(feature = "v1")] mod batching { use payjoin::send::v1::SenderBuilder; use payjoin::UriExt; From 5e678c9b42c81eca4ba100aa2a2da3346d3283f8 Mon Sep 17 00:00:00 2001 From: DanGould Date: Mon, 20 Jan 2025 16:03:01 -0500 Subject: [PATCH 3/5] Introduce `directory` feature module Allow payjoin and payjoin-directory to share ShortId code. --- Cargo-minimal.lock | 1 + Cargo-recent.lock | 1 + payjoin-directory/Cargo.toml | 1 + payjoin-directory/src/db.rs | 24 ++++++++----- payjoin-directory/src/lib.rs | 39 +++++++++------------ payjoin/Cargo.toml | 3 +- payjoin/src/bech32.rs | 11 +++--- payjoin/src/directory.rs | 67 ++++++++++++++++++++++++++++++++++++ payjoin/src/lib.rs | 4 ++- payjoin/src/uri/mod.rs | 60 ++------------------------------ 10 files changed, 116 insertions(+), 95 deletions(-) create mode 100644 payjoin/src/directory.rs diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index d9921c71e..2507aa1a7 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -1674,6 +1674,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "payjoin", "redis", "rustls 0.22.4", "tokio", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index d9921c71e..2507aa1a7 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -1674,6 +1674,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", + "payjoin", "redis", "rustls 0.22.4", "tokio", diff --git a/payjoin-directory/Cargo.toml b/payjoin-directory/Cargo.toml index 1c87098f2..933062ec9 100644 --- a/payjoin-directory/Cargo.toml +++ b/payjoin-directory/Cargo.toml @@ -26,6 +26,7 @@ hyper = { version = "1", features = ["http1", "server"] } hyper-rustls = { version = "0.26", optional = true } hyper-util = { version = "0.1", features = ["tokio"] } ohttp = { package = "bitcoin-ohttp", version = "0.6.0"} +payjoin = { version = "0.22.0", features = ["directory"], default-features = false } redis = { version = "0.23.3", features = ["aio", "tokio-comp"] } rustls = { version = "0.22.4", optional = true } tokio = { version = "1.12.0", features = ["full"] } diff --git a/payjoin-directory/src/db.rs b/payjoin-directory/src/db.rs index 7e9c2eec1..647035333 100644 --- a/payjoin-directory/src/db.rs +++ b/payjoin-directory/src/db.rs @@ -1,6 +1,7 @@ use std::time::Duration; use futures::StreamExt; +use payjoin::directory::ShortId; use redis::{AsyncCommands, Client, ErrorKind, RedisError, RedisResult}; use tracing::debug; @@ -53,24 +54,29 @@ impl DbPool { } /// Peek using [`DEFAULT_COLUMN`] as the channel type. - pub async fn push_default(&self, subdirectory_id: &str, data: Vec) -> Result<()> { + pub async fn push_default(&self, subdirectory_id: &ShortId, data: Vec) -> Result<()> { self.push(subdirectory_id, DEFAULT_COLUMN, data).await } - pub async fn peek_default(&self, subdirectory_id: &str) -> Result> { + pub async fn peek_default(&self, subdirectory_id: &ShortId) -> Result> { self.peek_with_timeout(subdirectory_id, DEFAULT_COLUMN).await } - pub async fn push_v1(&self, subdirectory_id: &str, data: Vec) -> Result<()> { + pub async fn push_v1(&self, subdirectory_id: &ShortId, data: Vec) -> Result<()> { self.push(subdirectory_id, PJ_V1_COLUMN, data).await } /// Peek using [`PJ_V1_COLUMN`] as the channel type. - pub async fn peek_v1(&self, subdirectory_id: &str) -> Result> { + pub async fn peek_v1(&self, subdirectory_id: &ShortId) -> Result> { self.peek_with_timeout(subdirectory_id, PJ_V1_COLUMN).await } - async fn push(&self, subdirectory_id: &str, channel_type: &str, data: Vec) -> Result<()> { + async fn push( + &self, + subdirectory_id: &ShortId, + channel_type: &str, + data: Vec, + ) -> Result<()> { let mut conn = self.client.get_async_connection().await?; let key = channel_name(subdirectory_id, channel_type); () = conn.set(&key, data.clone()).await?; @@ -80,7 +86,7 @@ impl DbPool { async fn peek_with_timeout( &self, - subdirectory_id: &str, + subdirectory_id: &ShortId, channel_type: &str, ) -> Result> { match tokio::time::timeout(self.timeout, self.peek(subdirectory_id, channel_type)).await { @@ -92,7 +98,7 @@ impl DbPool { } } - async fn peek(&self, subdirectory_id: &str, channel_type: &str) -> RedisResult> { + async fn peek(&self, subdirectory_id: &ShortId, channel_type: &str) -> RedisResult> { let mut conn = self.client.get_async_connection().await?; let key = channel_name(subdirectory_id, channel_type); @@ -140,6 +146,6 @@ impl DbPool { } } -fn channel_name(subdirectory_id: &str, channel_type: &str) -> Vec { - (subdirectory_id.to_owned() + channel_type).into_bytes() +fn channel_name(subdirectory_id: &ShortId, channel_type: &str) -> Vec { + (subdirectory_id.to_string() + channel_type).into_bytes() } diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index 3f921b5ef..f95ef2196 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -1,4 +1,5 @@ use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -11,6 +12,7 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode, Uri}; use hyper_util::rt::TokioIo; +use payjoin::directory::{ShortId, ShortIdError}; use tokio::net::TcpListener; use tokio::sync::Mutex; use tracing::{debug, error, info, trace}; @@ -32,9 +34,6 @@ const V1_REJECT_RES_JSON: &str = r#"{{"errorCode": "original-psbt-rejected ", "message": "Body is not a string"}}"#; const V1_UNAVAILABLE_RES_JSON: &str = r#"{{"errorCode": "unavailable", "message": "V2 receiver offline. V1 sends require synchronous communications."}}"#; -// 8 bytes as bech32 is 12.8 characters -const ID_LENGTH: usize = 13; - mod db; #[cfg(feature = "_danger-local-https")] @@ -313,6 +312,12 @@ impl From for HandlerError { fn from(e: hyper::http::Error) -> Self { HandlerError::InternalServerError(e.into()) } } +impl From for HandlerError { + fn from(_: ShortIdError) -> Self { + HandlerError::BadRequest(anyhow::anyhow!("subdirectory ID must be 13 bech32 characters")) + } +} + fn handle_peek( result: db::Result>, timeout_response: Response>, @@ -353,11 +358,11 @@ async fn post_fallback_v1( }; let v2_compat_body = format!("{}\n{}", body_str, query); - let id = check_id_length(id)?; - pool.push_default(id, v2_compat_body.into()) + let id = ShortId::from_str(id)?; + pool.push_default(&id, v2_compat_body.into()) .await .map_err(|e| HandlerError::BadRequest(e.into()))?; - handle_peek(pool.peek_v1(id).await, none_response) + handle_peek(pool.peek_v1(&id).await, none_response) } async fn put_payjoin_v1( @@ -368,29 +373,19 @@ async fn put_payjoin_v1( trace!("Put_payjoin_v1"); let ok_response = Response::builder().status(StatusCode::OK).body(empty())?; - let id = check_id_length(id)?; + let id = ShortId::from_str(id)?; let req = body.collect().await.map_err(|e| HandlerError::InternalServerError(e.into()))?.to_bytes(); if req.len() > V1_MAX_BUFFER_SIZE { return Err(HandlerError::PayloadTooLarge); } - match pool.push_v1(id, req.into()).await { + match pool.push_v1(&id, req.into()).await { Ok(_) => Ok(ok_response), Err(e) => Err(HandlerError::BadRequest(e.into())), } } -fn check_id_length(id: &str) -> Result<&str, HandlerError> { - if id.len() != ID_LENGTH { - return Err(HandlerError::BadRequest(anyhow::anyhow!( - "subdirectory ID must be 13 bech32 characters", - ))); - } - - Ok(id) -} - async fn post_subdir( id: &str, body: BoxBody, @@ -399,7 +394,7 @@ async fn post_subdir( let none_response = Response::builder().status(StatusCode::OK).body(empty())?; trace!("post_subdir"); - let id = check_id_length(id)?; + let id = ShortId::from_str(id)?; let req = body.collect().await.map_err(|e| HandlerError::InternalServerError(e.into()))?.to_bytes(); @@ -407,7 +402,7 @@ async fn post_subdir( return Err(HandlerError::PayloadTooLarge); } - match pool.push_default(id, req.into()).await { + match pool.push_default(&id, req.into()).await { Ok(_) => Ok(none_response), Err(e) => Err(HandlerError::BadRequest(e.into())), } @@ -418,9 +413,9 @@ async fn get_subdir( pool: DbPool, ) -> Result>, HandlerError> { trace!("get_subdir"); - let id = check_id_length(id)?; + let id = ShortId::from_str(id)?; let timeout_response = Response::builder().status(StatusCode::ACCEPTED).body(empty())?; - handle_peek(pool.peek_default(id).await, timeout_response) + handle_peek(pool.peek_default(&id).await, timeout_response) } fn not_found() -> Response> { diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 03a629140..bec31f8f6 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -20,8 +20,9 @@ default = ["v2"] base64 = ["bitcoin/base64"] #[doc = "Core features for payjoin state machines"] _core = ["bitcoin/rand", "serde_json", "url", "bitcoin_uri"] +directory = [] v1 = ["_core"] -v2 = ["_core", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "serde", "url/serde"] +v2 = ["_core", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "serde", "url/serde", "directory"] #[doc = "Functions to fetch OHTTP keys via CONNECT proxy using reqwest. Enables `v2` since only `v2` uses OHTTP."] io = ["v2", "reqwest/rustls-tls"] _danger-local-https = ["reqwest/rustls-tls", "rustls"] diff --git a/payjoin/src/bech32.rs b/payjoin/src/bech32.rs index 59eb3105f..0b7d80012 100644 --- a/payjoin/src/bech32.rs +++ b/payjoin/src/bech32.rs @@ -1,5 +1,3 @@ -use std::fmt; - use bitcoin::bech32::primitives::decode::{CheckedHrpstring, CheckedHrpstringError}; use bitcoin::bech32::{self, EncodeError, Hrp, NoChecksum}; @@ -15,8 +13,13 @@ pub mod nochecksum { bech32::encode_upper::(hrp, data) } - pub fn encode_to_fmt(f: &mut fmt::Formatter, hrp: Hrp, data: &[u8]) -> Result<(), EncodeError> { - bech32::encode_upper_to_fmt::(f, hrp, data) + #[cfg(feature = "v2")] + pub fn encode_to_fmt( + f: &mut core::fmt::Formatter, + hrp: Hrp, + data: &[u8], + ) -> Result<(), EncodeError> { + bech32::encode_upper_to_fmt::(f, hrp, data) } } diff --git a/payjoin/src/directory.rs b/payjoin/src/directory.rs new file mode 100644 index 000000000..53d261dda --- /dev/null +++ b/payjoin/src/directory.rs @@ -0,0 +1,67 @@ +/// A 64-bit identifier used to identify Payjoin Directory entries. +/// +/// ShortId is derived from a truncated SHA256 hash of a compressed public key. While SHA256 is used +/// internally, ShortIds should be treated only as unique identifiers, not cryptographic hashes. +/// The truncation to 64 bits means they are not cryptographically binding. +/// +/// ## Security Characteristics +/// +/// - Provides sufficient entropy for practical uniqueness in the Payjoin Directory context +/// - With ~2^21 concurrent entries (24h tx limit), collision probability is < 1e-6 +/// - Individual entry collision probability is << 1e-10 +/// - Collisions only affect liveness (ability to complete the payjoin), not security +/// - For v2 entries, collisions result in HPKE failure +/// - For v1 entries, collisions may leak PSBT proposals to interceptors +/// +/// Note: This implementation assumes ephemeral public keys with sufficient entropy. The short length +/// is an intentional tradeoff that provides adequate practical uniqueness while reducing DoS surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShortId(pub [u8; 8]); + +impl ShortId { + pub fn as_bytes(&self) -> &[u8] { &self.0 } + pub fn as_slice(&self) -> &[u8] { &self.0 } +} + +impl std::fmt::Display for ShortId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let id_hrp = bitcoin::bech32::Hrp::parse("ID").unwrap(); + f.write_str( + crate::bech32::nochecksum::encode(id_hrp, &self.0) + .expect("bech32 encoding of short ID must succeed") + .strip_prefix("ID1") + .expect("human readable part must be ID1"), + ) + } +} + +#[derive(Debug)] +pub enum ShortIdError { + DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), + IncorrectLength(std::array::TryFromSliceError), +} + +impl std::convert::From for ShortId { + fn from(h: bitcoin::hashes::sha256::Hash) -> Self { + bitcoin::hashes::Hash::as_byte_array(&h)[..8] + .try_into() + .expect("truncating SHA256 to 8 bytes should always succeed") + } +} + +impl std::convert::TryFrom<&[u8]> for ShortId { + type Error = ShortIdError; + fn try_from(bytes: &[u8]) -> Result { + let bytes: [u8; 8] = bytes.try_into().map_err(ShortIdError::IncorrectLength)?; + Ok(Self(bytes)) + } +} + +impl std::str::FromStr for ShortId { + type Err = ShortIdError; + fn from_str(s: &str) -> Result { + let (_, bytes) = crate::bech32::nochecksum::decode(&("ID1".to_string() + s)) + .map_err(ShortIdError::DecodeBech32)?; + (&bytes[..]).try_into() + } +} diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index 875ef1eee..63066c896 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -33,8 +33,10 @@ pub use crate::hpke::{HpkeKeyPair, HpkePublicKey}; pub(crate) mod ohttp; #[cfg(feature = "v2")] pub use crate::ohttp::OhttpKeys; -#[cfg(feature = "v2")] +#[cfg(any(feature = "v2", feature = "directory"))] pub(crate) mod bech32; +#[cfg(feature = "directory")] +pub mod directory; #[cfg(feature = "io")] pub mod io; diff --git a/payjoin/src/uri/mod.rs b/payjoin/src/uri/mod.rs index e6aacece8..8643e57d0 100644 --- a/payjoin/src/uri/mod.rs +++ b/payjoin/src/uri/mod.rs @@ -4,6 +4,8 @@ use bitcoin::address::NetworkChecked; pub use error::PjParseError; use url::Url; +#[cfg(feature = "v2")] +pub(crate) use crate::directory::ShortId; use crate::uri::error::InternalPjParseError; #[cfg(feature = "v2")] pub(crate) use crate::uri::url_ext::UrlExt; @@ -12,64 +14,6 @@ pub mod error; #[cfg(feature = "v2")] pub(crate) mod url_ext; -#[cfg(feature = "v2")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ShortId(pub [u8; 8]); - -#[cfg(feature = "v2")] -impl ShortId { - pub fn as_bytes(&self) -> &[u8] { &self.0 } - pub fn as_slice(&self) -> &[u8] { &self.0 } -} - -#[cfg(feature = "v2")] -impl std::fmt::Display for ShortId { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - let id_hrp = bitcoin::bech32::Hrp::parse("ID").unwrap(); - f.write_str( - crate::bech32::nochecksum::encode(id_hrp, &self.0) - .expect("bech32 encoding of short ID must succeed") - .strip_prefix("ID1") - .expect("human readable part must be ID1"), - ) - } -} - -#[cfg(feature = "v2")] -#[derive(Debug)] -pub enum ShortIdError { - DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), - IncorrectLength(std::array::TryFromSliceError), -} - -#[cfg(feature = "v2")] -impl std::convert::From for ShortId { - fn from(h: bitcoin::hashes::sha256::Hash) -> Self { - bitcoin::hashes::Hash::as_byte_array(&h)[..8] - .try_into() - .expect("truncating SHA256 to 8 bytes should always succeed") - } -} - -#[cfg(feature = "v2")] -impl std::convert::TryFrom<&[u8]> for ShortId { - type Error = ShortIdError; - fn try_from(bytes: &[u8]) -> Result { - let bytes: [u8; 8] = bytes.try_into().map_err(ShortIdError::IncorrectLength)?; - Ok(Self(bytes)) - } -} - -#[cfg(feature = "v2")] -impl std::str::FromStr for ShortId { - type Err = ShortIdError; - fn from_str(s: &str) -> Result { - let (_, bytes) = crate::bech32::nochecksum::decode(&("ID1".to_string() + s)) - .map_err(ShortIdError::DecodeBech32)?; - (&bytes[..]).try_into() - } -} - #[derive(Debug, Clone)] pub enum MaybePayjoinExtras { Supported(PayjoinExtras), From 5aaf0689ca0d4caa33267726ca974455048aa47e Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 21 Jan 2025 15:12:14 -0500 Subject: [PATCH 4/5] Define ENCAPSULATED_MESSAGE_BYTES in `directory` This is a BIP 77 directory-specific constant --- payjoin-directory/src/lib.rs | 3 +-- payjoin/src/directory.rs | 2 ++ payjoin/src/ohttp.rs | 3 ++- payjoin/src/receive/v2/error.rs | 2 +- payjoin/src/receive/v2/mod.rs | 12 ++++++------ payjoin/src/request.rs | 5 ++++- payjoin/src/send/v2/mod.rs | 14 ++++++-------- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index f95ef2196..b3ea180b6 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -12,7 +12,7 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode, Uri}; use hyper_util::rt::TokioIo; -use payjoin::directory::{ShortId, ShortIdError}; +use payjoin::directory::{ShortId, ShortIdError, ENCAPSULATED_MESSAGE_BYTES}; use tokio::net::TcpListener; use tokio::sync::Mutex; use tracing::{debug, error, info, trace}; @@ -23,7 +23,6 @@ pub const DEFAULT_DIR_PORT: u16 = 8080; pub const DEFAULT_DB_HOST: &str = "localhost:6379"; pub const DEFAULT_TIMEOUT_SECS: u64 = 30; -const ENCAPSULATED_MESSAGE_BYTES: usize = 8192; const CHACHA20_POLY1305_NONCE_LEN: usize = 32; // chacha20poly1305 n_k const POLY1305_TAG_SIZE: usize = 16; pub const BHTTP_REQ_BYTES: usize = diff --git a/payjoin/src/directory.rs b/payjoin/src/directory.rs index 53d261dda..87de53d38 100644 --- a/payjoin/src/directory.rs +++ b/payjoin/src/directory.rs @@ -1,3 +1,5 @@ +pub const ENCAPSULATED_MESSAGE_BYTES: usize = 8192; + /// A 64-bit identifier used to identify Payjoin Directory entries. /// /// ShortId is derived from a truncated SHA256 hash of a compressed public key. While SHA256 is used diff --git a/payjoin/src/ohttp.rs b/payjoin/src/ohttp.rs index f62ee6fed..73bc22689 100644 --- a/payjoin/src/ohttp.rs +++ b/payjoin/src/ohttp.rs @@ -4,7 +4,8 @@ use std::{error, fmt}; use bitcoin::bech32::{self, EncodeError}; use bitcoin::key::constants::UNCOMPRESSED_PUBLIC_KEY_SIZE; -pub const ENCAPSULATED_MESSAGE_BYTES: usize = 8192; +use crate::directory::ENCAPSULATED_MESSAGE_BYTES; + const N_ENC: usize = UNCOMPRESSED_PUBLIC_KEY_SIZE; const N_T: usize = crate::hpke::POLY1305_TAG_SIZE; const OHTTP_REQ_HEADER_BYTES: usize = 7; diff --git a/payjoin/src/receive/v2/error.rs b/payjoin/src/receive/v2/error.rs index a87673db8..4a784222a 100644 --- a/payjoin/src/receive/v2/error.rs +++ b/payjoin/src/receive/v2/error.rs @@ -59,7 +59,7 @@ impl fmt::Display for SessionError { f, "Unexpected response size {}, expected {} bytes", size, - crate::ohttp::ENCAPSULATED_MESSAGE_BYTES + crate::directory::ENCAPSULATED_MESSAGE_BYTES ), InternalSessionError::UnexpectedStatusCode(status) => write!(f, "Unexpected status code: {}", status), diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 766045950..5d9bce8a6 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -114,7 +114,7 @@ impl Receiver { body: &[u8], context: ohttp::ClientResponse, ) -> Result, Error> { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = + let response_array: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES] = body.try_into().map_err(|_| { Error::Validation(InternalSessionError::UnexpectedResponseSize(body.len()).into()) })?; @@ -135,7 +135,7 @@ impl Receiver { fn fallback_req_body( &mut self, ) -> Result< - ([u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES], ohttp::ClientResponse), + ([u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES], ohttp::ClientResponse), OhttpEncapsulationError, > { let fallback_target = subdir(&self.context.directory, &self.id()); @@ -277,9 +277,9 @@ impl UncheckedProposal { body: &[u8], context: ohttp::ClientResponse, ) -> Result<(), SessionError> { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = body - .try_into() - .map_err(|_| InternalSessionError::UnexpectedResponseSize(body.len()))?; + let response_array: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES] = + body.try_into() + .map_err(|_| InternalSessionError::UnexpectedResponseSize(body.len()))?; let response = ohttp_decapsulate(context, response_array) .map_err(InternalSessionError::OhttpEncapsulation)?; @@ -540,7 +540,7 @@ impl PayjoinProposal { res: &[u8], ohttp_context: ohttp::ClientResponse, ) -> Result<(), Error> { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = + let response_array: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES] = res.try_into().map_err(|_| { Error::Validation(InternalSessionError::UnexpectedResponseSize(res.len()).into()) })?; diff --git a/payjoin/src/request.rs b/payjoin/src/request.rs index 8dd83f9e1..0adbf3b72 100644 --- a/payjoin/src/request.rs +++ b/payjoin/src/request.rs @@ -34,7 +34,10 @@ impl Request { /// Construct a new v2 request. #[cfg(feature = "v2")] - pub(crate) fn new_v2(url: Url, body: [u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES]) -> Self { + pub(crate) fn new_v2( + url: Url, + body: [u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES], + ) -> Self { Self { url, content_type: V2_REQ_CONTENT_TYPE, body: body.to_vec() } } } diff --git a/payjoin/src/send/v2/mod.rs b/payjoin/src/send/v2/mod.rs index 57cf8232e..33d0faada 100644 --- a/payjoin/src/send/v2/mod.rs +++ b/payjoin/src/send/v2/mod.rs @@ -224,10 +224,9 @@ pub struct V2PostContext { impl V2PostContext { pub fn process_response(self, response: &[u8]) -> Result { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = - response - .try_into() - .map_err(|_| InternalEncapsulationError::InvalidSize(response.len()))?; + let response_array: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES] = response + .try_into() + .map_err(|_| InternalEncapsulationError::InvalidSize(response.len()))?; let response = ohttp_decapsulate(self.ohttp_ctx, response_array) .map_err(InternalEncapsulationError::Ohttp)?; match response.status() { @@ -283,10 +282,9 @@ impl V2GetContext { response: &[u8], ohttp_ctx: ohttp::ClientResponse, ) -> Result, ResponseError> { - let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] = - response - .try_into() - .map_err(|_| InternalEncapsulationError::InvalidSize(response.len()))?; + let response_array: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES] = response + .try_into() + .map_err(|_| InternalEncapsulationError::InvalidSize(response.len()))?; let response = ohttp_decapsulate(ohttp_ctx, response_array) .map_err(InternalEncapsulationError::Ohttp)?; From 6580519c3ab26d366361208607d362706ca54ffd Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 22 Jan 2025 20:33:42 -0500 Subject: [PATCH 5/5] Unit test `UncheckedProposal::from_request` The `exclusive` module separation stopped this unit from being tested. --- payjoin/src/receive/v1/exclusive/mod.rs | 41 +++++++++++++++++++++++++ payjoin/src/receive/v1/mod.rs | 19 ++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/payjoin/src/receive/v1/exclusive/mod.rs b/payjoin/src/receive/v1/exclusive/mod.rs index a7305eac7..df390ac65 100644 --- a/payjoin/src/receive/v1/exclusive/mod.rs +++ b/payjoin/src/receive/v1/exclusive/mod.rs @@ -63,3 +63,44 @@ impl UncheckedProposal { Ok(UncheckedProposal { psbt, params }) } } + +#[cfg(test)] +mod tests { + use bitcoin::{Address, AddressType}; + + use super::*; + struct MockHeaders { + length: String, + } + + impl MockHeaders { + fn new(length: u64) -> MockHeaders { MockHeaders { length: length.to_string() } } + } + + impl Headers for MockHeaders { + fn get_header(&self, key: &str) -> Option<&str> { + match key { + "content-length" => Some(&self.length), + "content-type" => Some("text/plain"), + _ => None, + } + } + } + + #[test] + fn test_from_request() -> Result<(), Box> { + let body = super::test::ORIGINAL_PSBT.as_bytes(); + let headers = MockHeaders::new(body.len() as u64); + let proposal = UncheckedProposal::from_request(body, super::test::QUERY_PARAMS, headers)?; + + let witness_utxo = + proposal.psbt.inputs[0].witness_utxo.as_ref().expect("witness_utxo should be present"); + let address = + Address::from_script(&witness_utxo.script_pubkey, bitcoin::params::Params::MAINNET)?; + assert_eq!(address.address_type(), Some(AddressType::P2sh)); + + assert_eq!(proposal.params.v, 1); + assert_eq!(proposal.params.additional_fee_contribution, Some((Amount::from_sat(182), 0))); + Ok(()) + } +} diff --git a/payjoin/src/receive/v1/mod.rs b/payjoin/src/receive/v1/mod.rs index be2799b39..9ae0593ec 100644 --- a/payjoin/src/receive/v1/mod.rs +++ b/payjoin/src/receive/v1/mod.rs @@ -834,19 +834,18 @@ pub(crate) mod test { use super::*; + // OriginalPSBT Test Vector from BIP + // | InputScriptType | Orginal PSBT Fee rate | maxadditionalfeecontribution | additionalfeeoutputindex| + // |-----------------|-----------------------|------------------------------|-------------------------| + // | P2SH-P2WPKH | 2 sat/vbyte | 0.00000182 | 0 | + pub const ORIGINAL_PSBT: &str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + pub const QUERY_PARAMS: &str = "maxadditionalfeecontribution=182&additionalfeeoutputindex=0"; + pub(crate) fn proposal_from_test_vector( ) -> Result> { - // OriginalPSBT Test Vector from BIP - // | InputScriptType | Orginal PSBT Fee rate | maxadditionalfeecontribution | additionalfeeoutputindex| - // |-----------------|-----------------------|------------------------------|-------------------------| - // | P2SH-P2WPKH | 2 sat/vbyte | 0.00000182 | 0 | - let original_psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; - - let pairs = url::form_urlencoded::parse( - "maxadditionalfeecontribution=182&additionalfeeoutputindex=0".as_bytes(), - ); + let pairs = url::form_urlencoded::parse(QUERY_PARAMS.as_bytes()); let params = Params::from_query_pairs(pairs, &[1])?; - Ok(UncheckedProposal { psbt: bitcoin::Psbt::from_str(original_psbt)?, params }) + Ok(UncheckedProposal { psbt: bitcoin::Psbt::from_str(ORIGINAL_PSBT)?, params }) } #[test]