From d7b7a6c82869cd843a1fd81c7dc493cdaaf670f1 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Sat, 6 Jun 2026 22:21:09 -0300 Subject: [PATCH 1/9] refactor: introduce no_std/alloc feature split in payjoin core --- payjoin/src/bech32.rs | 5 + payjoin/src/core/error.rs | 39 +- payjoin/src/core/hpke.rs | 7 + payjoin/src/core/into_url.rs | 9 +- payjoin/src/core/io.rs | 3 +- payjoin/src/core/mod.rs | 22 +- payjoin/src/core/no_std_tests.rs | 89 +++++ payjoin/src/core/ohttp.rs | 39 +- payjoin/src/core/psbt/mod.rs | 18 +- payjoin/src/core/receive/common/mod.rs | 30 +- payjoin/src/core/receive/error.rs | 42 ++- payjoin/src/core/receive/mod.rs | 29 +- .../src/core/receive/optional_parameters.rs | 18 +- payjoin/src/core/receive/v1/error.rs | 3 + payjoin/src/core/request.rs | 8 +- payjoin/src/core/send/error.rs | 1 + payjoin/src/core/send/mod.rs | 15 +- payjoin/src/core/send/v1.rs | 14 +- payjoin/src/core/time.rs | 38 +- payjoin/src/core/uri/error.rs | 22 +- payjoin/src/core/uri/mod.rs | 333 ++++++++++++++---- payjoin/src/core/uri/v2.rs | 131 +++++-- payjoin/src/core/url.rs | 16 +- payjoin/src/directory.rs | 29 +- payjoin/src/lib.rs | 12 +- 25 files changed, 760 insertions(+), 212 deletions(-) create mode 100644 payjoin/src/core/no_std_tests.rs diff --git a/payjoin/src/bech32.rs b/payjoin/src/bech32.rs index e9288e6c2..ea91a613a 100644 --- a/payjoin/src/bech32.rs +++ b/payjoin/src/bech32.rs @@ -1,3 +1,8 @@ +#[cfg(feature = "alloc")] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; + use bitcoin::bech32::primitives::decode::{CheckedHrpstring, CheckedHrpstringError}; use bitcoin::bech32::{self, EncodeError, Hrp, NoChecksum}; diff --git a/payjoin/src/core/error.rs b/payjoin/src/core/error.rs index 35857fd1f..8d7b3289b 100644 --- a/payjoin/src/core/error.rs +++ b/payjoin/src/core/error.rs @@ -1,6 +1,30 @@ -use std::fmt::Debug; -use std::{error, fmt}; +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; +#[cfg(feature = "v2")] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use core::error; +use core::fmt::{self, Debug}; +#[cfg(feature = "std")] +use std::error; +#[derive(Debug)] +pub struct StdRequiredError; + +impl fmt::Display for StdRequiredError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "std is required for this operation") + } +} + +#[cfg(feature = "std")] +impl std::error::Error for StdRequiredError {} +#[cfg(not(feature = "std"))] +impl core::error::Error for StdRequiredError {} + +impl ImplementationError { + pub fn std_required() -> Self { ImplementationError(Box::new(StdRequiredError)) } +} #[derive(Debug)] pub struct ImplementationError(Box); @@ -11,7 +35,7 @@ impl ImplementationError { } impl fmt::Display for ImplementationError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { std::fmt::Display::fmt(&self.0, f) } + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fmt::Display::fmt(&self.0, f) } } impl error::Error for ImplementationError { @@ -34,16 +58,17 @@ impl From<&str> for ImplementationError { ImplementationError::from(error) } } + /// Errors that can occur when replaying a session event log #[cfg(feature = "v2")] #[derive(Debug)] pub struct ReplayError(InternalReplayError); #[cfg(feature = "v2")] -impl std::fmt::Display +impl fmt::Display for ReplayError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use InternalReplayError::*; match &self.0 { NoEvents => write!(f, "No events found in session"), @@ -58,8 +83,9 @@ impl std::fmt::Display } } } + #[cfg(feature = "v2")] -impl std::error::Error +impl error::Error for ReplayError { } @@ -93,6 +119,7 @@ impl ReplayError { #[cfg(feature = "v2")] #[derive(Debug)] +#[allow(dead_code)] pub(crate) enum InternalReplayError { /// No events in the event log NoEvents, diff --git a/payjoin/src/core/hpke.rs b/payjoin/src/core/hpke.rs index a66f73140..1f4ef94cd 100644 --- a/payjoin/src/core/hpke.rs +++ b/payjoin/src/core/hpke.rs @@ -1,4 +1,9 @@ +#![cfg(any(feature = "v2", feature = "v2-ohttp"))] +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use core::error; use core::fmt; +#[cfg(feature = "std")] use std::error; use bitcoin::key::constants::{ELLSWIFT_ENCODING_SIZE, PUBLIC_KEY_SIZE}; @@ -182,6 +187,7 @@ pub fn decrypt_message_a( message_a: &[u8], receiver_sk: &HpkeSecretKey, ) -> Result<(Vec, HpkePublicKey), HpkeError> { + #[cfg(feature = "std")] use std::io::{Cursor, Read}; let mut cursor = Cursor::new(message_a); @@ -230,6 +236,7 @@ pub fn encrypt_message_b( Ok(message_b) } +#[cfg(feature = "std")] pub fn decrypt_message_b( message_b: &[u8], receiver_pk: HpkePublicKey, diff --git a/payjoin/src/core/into_url.rs b/payjoin/src/core/into_url.rs index c8f5c9aaf..bc7bd7b2b 100644 --- a/payjoin/src/core/into_url.rs +++ b/payjoin/src/core/into_url.rs @@ -1,3 +1,6 @@ +use alloc::string::String; +use core::{error, fmt}; + use crate::core::{Url, UrlParseError}; #[derive(Debug, PartialEq, Eq)] @@ -7,8 +10,8 @@ pub enum Error { ParseError(UrlParseError), } -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use Error::*; match self { @@ -18,7 +21,7 @@ impl std::fmt::Display for Error { } } -impl std::error::Error for Error {} +impl error::Error for Error {} impl From for Error { fn from(err: UrlParseError) -> Error { Error::ParseError(err) } diff --git a/payjoin/src/core/io.rs b/payjoin/src/core/io.rs index 05994c1a6..af989d09f 100644 --- a/payjoin/src/core/io.rs +++ b/payjoin/src/core/io.rs @@ -1,11 +1,12 @@ //! IO-related types and functions. Specifically, fetching OHTTP keys from a payjoin directory. +#[cfg(feature = "std")] use std::time::Duration; use http::header::ACCEPT; use reqwest::{Client, Proxy}; use crate::into_url::IntoUrl; -use crate::OhttpKeys; +use crate::ohttp::OhttpKeys; /// Fetch the ohttp keys from the specified payjoin directory via proxy. /// diff --git a/payjoin/src/core/mod.rs b/payjoin/src/core/mod.rs index ce01b7c6e..5b2f06d8e 100644 --- a/payjoin/src/core/mod.rs +++ b/payjoin/src/core/mod.rs @@ -14,29 +14,41 @@ pub mod receive; mod request; pub mod send; pub use request::*; +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub(crate) mod into_url; +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub use into_url::{Error as IntoUrlError, IntoUrl}; pub(crate) mod url; +#[cfg(feature = "v2-ohttp")] pub use url::{ParseError as UrlParseError, Url}; + +#[cfg(not(feature = "v2-ohttp"))] +pub use crate::core::url::{ParseError as UrlParseError, Url}; #[cfg(feature = "v2")] pub mod time; pub mod uri; -pub use uri::{PjParam, PjParseError, PjUri, Uri, UriParseError}; +#[cfg(feature = "std")] +pub use uri::PjUri; +pub use uri::{PjParam, PjParseError}; +#[cfg(feature = "std")] +pub use uri::{Uri, UriExt}; pub(crate) mod error_codes; pub(crate) mod output_substitution; pub use output_substitution::OutputSubstitution; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] pub(crate) mod hpke; #[cfg(feature = "v2")] pub mod persist; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] pub use crate::hpke::{HpkeKeyPair, HpkePublicKey}; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] pub(crate) mod ohttp; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] pub use crate::ohttp::{OhttpKeys, OhttpKeysError, OhttpResponse}; +#[cfg(test)] +mod no_std_tests; #[cfg(feature = "io")] #[cfg_attr(docsrs, doc(cfg(feature = "io")))] diff --git a/payjoin/src/core/no_std_tests.rs b/payjoin/src/core/no_std_tests.rs new file mode 100644 index 000000000..1f8842dbd --- /dev/null +++ b/payjoin/src/core/no_std_tests.rs @@ -0,0 +1,89 @@ +use core::str::FromStr; + +use bitcoin::psbt::Psbt; +extern crate alloc; +use bitcoin::{consensus, Amount, FeeRate, Weight}; + +#[test] +fn test_fallback_tx_extracts_and_is_nonempty() { + let psbt_str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + + let psbt = Psbt::from_str(psbt_str).expect("psbt parse"); + let tx = psbt.extract_tx().expect("tx extract"); + + assert_eq!(tx.version.0, 2); + assert!(!tx.input.is_empty()); + assert!(!tx.output.is_empty()); +} + +#[cfg(feature = "v2")] +#[test] +fn test_uri_parsing_sets_amount() { + use core::convert::TryFrom; + + use crate::Uri; + + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ%23RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES"; + let parsed = Uri::try_from(uri).expect("uri parse"); + assert!(parsed.amount().is_some()); +} + +#[cfg(feature = "v2")] +#[test] +fn v2_uri_rejects_invalid_amount() { + use core::convert::TryFrom; + + use crate::Uri; + + let uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=not_a_number&pj=https://example.com/x"; + assert!(Uri::try_from(uri).is_err()); +} + +#[cfg(feature = "v2")] +#[test] +fn v2_uri_parsing_sets_amount() { + use core::convert::TryFrom; + + use crate::Uri; + + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ%23RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-EX1C4UC6ES"; + let parsed = Uri::try_from(uri).expect("uri parse"); + assert!(parsed.amount().is_some()); + assert_eq!(parsed.amount().unwrap(), Amount::from_btc(0.01).unwrap()); +} + +#[test] +fn alloc_vec_smoke() { + let mut v = alloc::vec::Vec::new(); + v.extend_from_slice(&[1u8, 2, 3, 4]); + assert_eq!(v.len(), 4); + assert_eq!(v[0], 1); + assert_eq!(v[3], 4); +} + +#[test] +fn fee_math_is_deterministic() { + let w = Weight::from_wu(400); // 100 vbytes + let fr = FeeRate::from_sat_per_vb_u32(2); + + // 100 vbytes * 2 sat/vb = 200 sats + let fee = w * fr; + assert_eq!(fee, Amount::from_sat(200)); +} + +#[test] +fn psbt_extract_tx_has_expected_shape() { + let psbt_str = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + + let psbt = Psbt::from_str(psbt_str).expect("psbt parse"); + let tx = psbt.extract_tx().expect("tx extract"); + + assert_eq!(tx.version.0, 2); + assert!(!tx.input.is_empty()); + assert!(!tx.output.is_empty()); + + let enc = consensus::encode::serialize(&tx); + let dec: bitcoin::Transaction = consensus::encode::deserialize(&enc).expect("tx decode"); + assert_eq!(tx, dec); +} diff --git a/payjoin/src/core/ohttp.rs b/payjoin/src/core/ohttp.rs index af61f8e06..fe231c689 100644 --- a/payjoin/src/core/ohttp.rs +++ b/payjoin/src/core/ohttp.rs @@ -1,4 +1,12 @@ -use std::{error, fmt}; +use alloc::vec; +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use core::error; +use core::fmt; +#[cfg(not(feature = "std"))] +use core::ops::{Deref, DerefMut}; +#[cfg(feature = "std")] +use std::error; use bitcoin::bech32::{self, EncodeError}; use bitcoin::key::constants::UNCOMPRESSED_PUBLIC_KEY_SIZE; @@ -18,10 +26,10 @@ pub(crate) fn ohttp_encapsulate( target_resource: &str, body: Option<&[u8]>, ) -> Result<([u8; ENCAPSULATED_MESSAGE_BYTES], ohttp::ClientResponse), OhttpEncapsulationError> { - use std::fmt::Write; - let mut ohttp_keys = ohttp_keys.0.clone(); + use core::fmt::Write; + let mut ohttp_keys = ohttp_keys.clone(); - let ctx = ohttp::ClientRequest::from_config(&mut ohttp_keys)?; + let ctx = ohttp::ClientRequest::from_config(&mut ohttp_keys.0)?; let url = crate::core::Url::parse(target_resource)?; let authority_bytes = { let mut authority = url.host_str(); @@ -55,7 +63,7 @@ pub(crate) fn ohttp_encapsulate( #[derive(Debug)] pub enum DirectoryResponseError { InvalidSize(usize), - OhttpDecapsulation(OhttpEncapsulationError), + OhttpDecapsulation(ohttp::Error), UnexpectedStatusCode(http::StatusCode), } @@ -100,6 +108,7 @@ impl error::Error for DirectoryResponseError { } } +#[cfg(feature = "std")] pub(crate) fn process_get_res( res: &[u8], ohttp_context: ohttp::ClientResponse, @@ -112,6 +121,7 @@ pub(crate) fn process_get_res( } } +#[cfg(feature = "std")] pub(crate) fn process_post_res( res: &[u8], ohttp_context: ohttp::ClientResponse, @@ -123,19 +133,22 @@ pub(crate) fn process_post_res( } } +#[cfg(feature = "std")] fn process_ohttp_res( res: &[u8], ohttp_context: ohttp::ClientResponse, ) -> Result>, DirectoryResponseError> { let response_array: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES] = res.try_into().map_err(|_| DirectoryResponseError::InvalidSize(res.len()))?; - tracing::trace!("decapsulating directory response"); - let res = ohttp_decapsulate(ohttp_context, response_array) - .map_err(DirectoryResponseError::OhttpDecapsulation)?; - Ok(res) + ohttp_decapsulate(ohttp_context, response_array).map_err(|e| match e { + OhttpEncapsulationError::Ohttp(ohttp_err) => + DirectoryResponseError::OhttpDecapsulation(ohttp_err), + _ => DirectoryResponseError::InvalidSize(0), + }) } /// decapsulate ohttp, bhttp response and return http response body and status code +#[cfg(all(feature = "std", feature = "v2-ohttp"))] pub(crate) fn ohttp_decapsulate( res_ctx: ohttp::ClientResponse, ohttp_body: &[u8; ENCAPSULATED_MESSAGE_BYTES], @@ -363,8 +376,8 @@ pub enum OhttpKeysError { InvalidFormat, } -impl std::fmt::Display for OhttpKeysError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for OhttpKeysError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use OhttpKeysError::*; match self { IncorrectLength(l) => write!(f, "Invalid length, got {l} expected 34"), @@ -377,8 +390,8 @@ impl std::fmt::Display for OhttpKeysError { } } -impl std::error::Error for OhttpKeysError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for OhttpKeysError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { use OhttpKeysError::*; match self { Decode(e) | Encode(e) => Some(e.as_ref()), diff --git a/payjoin/src/core/psbt/mod.rs b/payjoin/src/core/psbt/mod.rs index b649e5f9a..ab59c2e49 100644 --- a/payjoin/src/core/psbt/mod.rs +++ b/payjoin/src/core/psbt/mod.rs @@ -1,12 +1,20 @@ //! Utilities to make work with PSBTs easier +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; +#[cfg(not(feature = "std"))] +use alloc::collections::BTreeMap; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; +#[cfg(feature = "std")] use std::collections::BTreeMap; -use std::fmt; use bitcoin::address::FromScriptError; use bitcoin::psbt::Psbt; use bitcoin::transaction::InputWeightPrediction; use bitcoin::{bip32, psbt, Address, AddressType, Network, TxIn, TxOut, Weight}; + /// Shared non-witness weight for txid (32), index (4), and sequence (4) fields. /// We only need to add the weight of the txid: 32, index: 4 and sequence: 4 as rust_bitcoin /// already accounts for the scriptsig length when calculating InputWeightPrediction @@ -28,6 +36,7 @@ impl fmt::Display for InconsistentPsbt { } } +#[cfg(feature = "std")] impl std::error::Error for InconsistentPsbt {} /// Our Psbt type for validation and utilities @@ -199,7 +208,6 @@ impl InternalInputPair<'_> { // redeemScript can be extracted from scriptSig for signed P2SH inputs let redeem_script = if let Some(ref script_sig) = self.psbtin.final_script_sig { script_sig.redeem_script() - // try the PSBT redeem_script field for unsigned inputs. } else { self.psbtin.redeem_script.as_ref().map(|script| script.as_ref()) }; @@ -274,6 +282,7 @@ impl fmt::Display for PrevTxOutError { } } +#[cfg(feature = "std")] impl std::error::Error for PrevTxOutError {} #[derive(Debug, PartialEq, Eq)] @@ -303,6 +312,7 @@ impl fmt::Display for InternalPsbtInputError { } } +#[cfg(feature = "std")] impl std::error::Error for InternalPsbtInputError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { @@ -340,6 +350,7 @@ impl fmt::Display for PsbtInputError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } +#[cfg(feature = "std")] impl std::error::Error for PsbtInputError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.0) } } @@ -356,6 +367,7 @@ impl fmt::Display for PsbtInputsError { } } +#[cfg(feature = "std")] impl std::error::Error for PsbtInputsError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.error) } } @@ -379,6 +391,7 @@ impl fmt::Display for AddressTypeError { } } +#[cfg(feature = "std")] impl std::error::Error for AddressTypeError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { @@ -415,6 +428,7 @@ impl fmt::Display for InputWeightError { } } +#[cfg(feature = "std")] impl std::error::Error for InputWeightError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { diff --git a/payjoin/src/core/receive/common/mod.rs b/payjoin/src/core/receive/common/mod.rs index 5d2cc2d80..dacfa0370 100644 --- a/payjoin/src/core/receive/common/mod.rs +++ b/payjoin/src/core/receive/common/mod.rs @@ -2,12 +2,20 @@ //! This module isn't meant to be exposed publicly, but for v1 and v2 //! APIs to expose as relevant typestates. -use std::cmp::{max, min}; -use std::collections::HashSet; +extern crate alloc; + +use alloc::collections::BTreeSet as HashSet; +#[cfg(not(feature = "std"))] +use alloc::vec; +use alloc::vec::Vec; +use core::cmp::{max, min}; use bitcoin::psbt::Psbt; +use bitcoin::secp256k1::rand; +#[cfg(feature = "std")] use bitcoin::secp256k1::rand::seq::SliceRandom; -use bitcoin::secp256k1::rand::{self, Rng}; +#[cfg(feature = "std")] +use bitcoin::secp256k1::rand::Rng; use bitcoin::{Amount, FeeRate, Script, TxIn, TxOut, Weight}; use serde::{Deserialize, Serialize}; @@ -92,6 +100,7 @@ impl WantsOutputs { } /// Substitute the receiver output script with the provided script. + #[cfg(feature = "std")] pub fn substitute_receiver_script( self, output_script: &Script, @@ -114,6 +123,7 @@ impl WantsOutputs { /// example, if the receiver adds their own input, then the drain script output will have its /// value increased by the same amount. Or if an output needs to have its value reduced to /// account for fees, the value of the output for this script will be reduced. + #[cfg(feature = "std")] pub fn replace_receiver_outputs( self, replacement_outputs: impl IntoIterator, @@ -181,7 +191,10 @@ impl WantsOutputs { } } // Insert all remaining outputs at random indices for privacy + #[cfg(feature = "std")] interleave_shuffle(&mut outputs, &mut replacement_outputs, rng); + #[cfg(not(feature = "std"))] + interleave_shuffle(&mut outputs, &mut replacement_outputs); // Identify the receiver output that will be used for change and fees let change_vout = outputs.iter().position(|txo| txo.script_pubkey == *drain_script); // Update the payjoin PSBT outputs @@ -214,12 +227,12 @@ impl WantsOutputs { /// maintaining the relative order in `original` but randomly inserting elements from `new`. /// /// The combined result replaces the contents of `original`. +#[cfg(feature = "std")] fn interleave_shuffle(original: &mut Vec, new: &mut [T], rng: &mut R) { // Shuffle the substitute_outputs new.shuffle(rng); // Create a new vector to store the combined result let mut combined = Vec::with_capacity(original.len() + new.len()); - // Initialize indices let mut original_index = 0; let mut new_index = 0; // Interleave elements @@ -235,6 +248,11 @@ fn interleave_shuffle(original: &mut Vec, new: &mut [ *original = combined; } +#[cfg(not(feature = "std"))] +fn interleave_shuffle(original: &mut Vec, new: &mut [T]) { + original.extend_from_slice(new); +} + /// Typestate for a checked proposal which the receiver may contribute inputs to. /// /// Call [`Self::commit_inputs`] to proceed. @@ -365,11 +383,15 @@ impl WantsInputs { } // Insert contributions at random indices for privacy + #[cfg(feature = "std")] let mut rng = rand::thread_rng(); let mut receiver_input_amount = Amount::ZERO; for input_pair in inputs.clone() { receiver_input_amount += input_pair.previous_txout().value; + #[cfg(feature = "std")] let index = rng.gen_range(0..=self.proposal.payjoin_psbt.unsigned_tx.input.len()); + #[cfg(not(feature = "std"))] + let index = self.proposal.payjoin_psbt.unsigned_tx.input.len(); payjoin_psbt.inputs.insert(index, input_pair.psbtin); payjoin_psbt .unsigned_tx diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index d49066ae8..91462460f 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -1,5 +1,12 @@ -use std::{error, fmt}; - +#[cfg(feature = "std")] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use core::error; +use core::fmt; +#[cfg(feature = "std")] +use std::error; + +#[cfg(feature = "std")] use crate::error_codes::ErrorCode::{ self, NotEnoughMoney, OriginalPsbtRejected, Unavailable, VersionUnsupported, }; @@ -16,6 +23,7 @@ pub enum Error { Implementation(crate::ImplementationError), } +#[cfg(feature = "std")] impl From<&Error> for JsonReply { fn from(e: &Error) -> Self { match e { @@ -64,7 +72,7 @@ pub enum ProtocolError { /// Protocol-specific errors for BIP-78 v1 requests (e.g. HTTP request validation, parameter checks) #[cfg(feature = "v1")] V1(crate::receive::v1::RequestError), - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] /// V2-specific errors that are infeasable to reply to the sender V2(crate::receive::v2::SessionError), } @@ -78,6 +86,7 @@ pub enum ProtocolError { /// "message": "Human readable error message" /// } /// ``` +#[cfg(feature = "std")] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct JsonReply { /// The error code @@ -88,6 +97,7 @@ pub struct JsonReply { extra: serde_json::Map, } +#[cfg(feature = "std")] impl JsonReply { /// Create a new Reply pub(crate) fn new(error_code: ErrorCode, message: impl fmt::Display) -> Self { @@ -111,17 +121,18 @@ impl JsonReply { } /// Get the HTTP status code for the error + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub fn status_code(&self) -> u16 { match self.error_code { - ErrorCode::Unavailable => http::StatusCode::INTERNAL_SERVER_ERROR, + ErrorCode::Unavailable => 500, ErrorCode::NotEnoughMoney | ErrorCode::VersionUnsupported - | ErrorCode::OriginalPsbtRejected => http::StatusCode::BAD_REQUEST, + | ErrorCode::OriginalPsbtRejected => 400, } - .as_u16() } } +#[cfg(feature = "std")] impl From<&ProtocolError> for JsonReply { fn from(e: &ProtocolError) -> Self { use ProtocolError::*; @@ -129,7 +140,7 @@ impl From<&ProtocolError> for JsonReply { OriginalPayload(e) => e.into(), #[cfg(feature = "v1")] V1(e) => JsonReply::new(OriginalPsbtRejected, e), - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] V2(_) => JsonReply::new(Unavailable, "Receiver error"), } } @@ -141,19 +152,20 @@ impl fmt::Display for ProtocolError { Self::OriginalPayload(e) => e.fmt(f), #[cfg(feature = "v1")] Self::V1(e) => e.fmt(f), - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] Self::V2(e) => e.fmt(f), } } } impl error::Error for ProtocolError { + #[cfg(feature = "std")] fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self { Self::OriginalPayload(e) => e.source(), #[cfg(feature = "v1")] Self::V1(e) => e.source(), - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] Self::V2(e) => e.source(), } } @@ -188,8 +200,9 @@ impl From for PayloadError { #[derive(Debug)] pub(crate) enum InternalPayloadError { /// The payload is not valid utf-8 - Utf8(std::str::Utf8Error), + Utf8(core::str::Utf8Error), /// The payload is not a valid PSBT + #[cfg(feature = "std")] ParsePsbt(bitcoin::psbt::PsbtParseError), /// Invalid sender parameters SenderParams(super::optional_parameters::Error), @@ -218,6 +231,7 @@ pub(crate) enum InternalPayloadError { FeeTooHigh(bitcoin::FeeRate, bitcoin::FeeRate), } +#[cfg(feature = "std")] impl From<&PayloadError> for JsonReply { fn from(e: &PayloadError) -> Self { use InternalPayloadError::*; @@ -261,6 +275,7 @@ impl fmt::Display for InternalPayloadError { match &self { Utf8(e) => write!(f, "{e}"), + #[cfg(feature = "std")] ParsePsbt(e) => write!(f, "{e}"), SenderParams(e) => write!(f, "{e}"), InconsistentPsbt(e) => write!(f, "{e}"), @@ -281,6 +296,7 @@ impl fmt::Display for InternalPayloadError { } } +#[cfg(feature = "std")] impl std::error::Error for PayloadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use InternalPayloadError::*; @@ -338,8 +354,8 @@ impl From for OutputSubstitutionError { fn from(value: InternalOutputSubstitutionError) -> Self { OutputSubstitutionError(value) } } -impl std::error::Error for OutputSubstitutionError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for OutputSubstitutionError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self.0 { InternalOutputSubstitutionError::DecreasedValueWhenDisabled => None, InternalOutputSubstitutionError::ScriptPubKeyChangedWhenDisabled => None, @@ -434,7 +450,7 @@ impl From for InputContributionError { fn from(value: InternalInputContributionError) -> Self { InputContributionError(value) } } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod tests { use super::*; use crate::ImplementationError; diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 25952cc74..53476404c 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -9,17 +9,27 @@ //! If you specifically need to use //! version 1, refer to the `receive::v1` module documentation after enabling the `v1` feature. -use std::collections::BTreeMap; -use std::str::FromStr; +use alloc::collections::BTreeMap; +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use alloc::{format, vec}; +#[cfg(feature = "std")] +use core::str::FromStr; + +pub mod common; use bitcoin::transaction::InputWeightPrediction; +#[cfg(feature = "std")] +use bitcoin::FeeRate; use bitcoin::{ - psbt, AddressType, FeeRate, OutPoint, Psbt, Script, ScriptBuf, Transaction, TxIn, TxOut, Weight, + psbt, AddressType, OutPoint, Psbt, Script, ScriptBuf, Transaction, TxIn, TxOut, Weight, }; pub(crate) use error::InternalPayloadError; +#[cfg(feature = "std")] +pub use error::JsonReply; pub use error::{ - CoinSelectionError, Error, InputContributionError, JsonReply, OutputSubstitutionError, - PayloadError, ProtocolError, + CoinSelectionError, Error, InputContributionError, OutputSubstitutionError, PayloadError, + ProtocolError, }; use optional_parameters::Params; use serde::{Deserialize, Serialize}; @@ -29,7 +39,9 @@ use crate::psbt::{ InputWeightError, InternalInputPair, InternalPsbtInputError, PrevTxOutError, PsbtExt, NON_WITNESS_INPUT_WEIGHT, }; -use crate::{ImplementationError, Version}; +use crate::ImplementationError; +#[cfg(feature = "std")] +use crate::Version; /// Input weight for a P2TR key-spend with default sighash (64-byte signature) and no annex. const DEFAULT_SIGHASH_KEY_SPEND_INPUT_WEIGHT: Weight = Weight::from_wu( @@ -37,7 +49,6 @@ const DEFAULT_SIGHASH_KEY_SPEND_INPUT_WEIGHT: Weight = Weight::from_wu( + NON_WITNESS_INPUT_WEIGHT.to_wu(), ); -pub(crate) mod common; mod error; pub(crate) mod optional_parameters; @@ -47,6 +58,7 @@ pub mod v1; #[cfg(feature = "v2")] #[cfg_attr(docsrs, doc(cfg(feature = "v2")))] +#[cfg(feature = "v2-ohttp")] pub mod v2; /// A pair of ([`TxIn`], [`psbt::Input`]) with some built-in validation. @@ -232,6 +244,7 @@ impl<'a> From<&'a InputPair> for InternalInputPair<'a> { } /// Validate the payload of a Payjoin request for PSBT and Params sanity +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub(crate) fn parse_payload( base64: &str, query: &str, @@ -382,6 +395,7 @@ pub struct OriginalPayload { impl OriginalPayload { // Calculates the fee rate of the original proposal PSBT. + #[cfg(feature = "std")] fn psbt_fee_rate(&self) -> Result { let original_psbt_fee = self.psbt.fee().map_err(|e| { InternalPayloadError::ParsePsbt(bitcoin::psbt::PsbtParseError::PsbtEncoding(e)) @@ -389,6 +403,7 @@ impl OriginalPayload { Ok(original_psbt_fee / self.psbt.clone().extract_tx_unchecked_fee_rate().weight()) } + #[cfg(feature = "std")] pub fn check_broadcast_suitability( &self, min_fee_rate: Option, diff --git a/payjoin/src/core/receive/optional_parameters.rs b/payjoin/src/core/receive/optional_parameters.rs index e6716038e..bd1cc5897 100644 --- a/payjoin/src/core/receive/optional_parameters.rs +++ b/payjoin/src/core/receive/optional_parameters.rs @@ -1,5 +1,12 @@ -use std::borrow::Borrow; -use std::fmt; +#[cfg(feature = "std")] +use alloc::format; +use alloc::string::String; +use core::borrow::Borrow; +#[cfg(not(feature = "std"))] +use core::error; +use core::fmt; +#[cfg(feature = "std")] +use std::error; use bitcoin::FeeRate; use tracing::warn; @@ -99,7 +106,7 @@ impl Params { // 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 minimum, we want to round up - FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64) + FeeRate::from_sat_per_kwu((fee_rate_sat_per_kwu + 0.9999) as u64) } Err(_) => return Err(Error::FeeRate), }, @@ -122,6 +129,7 @@ impl Params { Ok(params) } + #[cfg(feature = "std")] pub fn from_query_str( query: &str, supported_versions: &'static [Version], @@ -149,8 +157,8 @@ impl fmt::Display for Error { } } -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { None } } #[cfg(test)] diff --git a/payjoin/src/core/receive/v1/error.rs b/payjoin/src/core/receive/v1/error.rs index ad438230f..eec90e08a 100644 --- a/payjoin/src/core/receive/v1/error.rs +++ b/payjoin/src/core/receive/v1/error.rs @@ -1,4 +1,7 @@ +#[cfg(not(feature = "std"))] +use core::error; use core::fmt; +#[cfg(feature = "std")] use std::error; /// Error that occurs during validation of an incoming v1 payjoin request. diff --git a/payjoin/src/core/request.rs b/payjoin/src/core/request.rs index abe51b611..7dc256abd 100644 --- a/payjoin/src/core/request.rs +++ b/payjoin/src/core/request.rs @@ -1,8 +1,12 @@ +use alloc::string::String; +use alloc::vec::Vec; + +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] use crate::core::Url; #[cfg(feature = "v1")] const V1_REQ_CONTENT_TYPE: &str = "text/plain"; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] const V2_REQ_CONTENT_TYPE: &str = "message/ohttp-req"; /// Represents data that needs to be transmitted to the receiver or payjoin directory. @@ -34,7 +38,7 @@ impl Request { } /// Construct a new v2 request. - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] pub(crate) fn new_v2( url: &Url, body: &[u8; crate::directory::ENCAPSULATED_MESSAGE_BYTES], diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 7143cc9cb..749a31c15 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -64,6 +64,7 @@ impl fmt::Display for BuildSenderError { } } +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] impl std::error::Error for BuildSenderError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use InternalBuildSenderError::*; diff --git a/payjoin/src/core/send/mod.rs b/payjoin/src/core/send/mod.rs index 4bd503b19..f910642e6 100644 --- a/payjoin/src/core/send/mod.rs +++ b/payjoin/src/core/send/mod.rs @@ -16,16 +16,22 @@ //! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), //! but request reuse makes correlation trivial for the relay. +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +use alloc::string::ToString; +use alloc::vec::Vec; + use bitcoin::psbt::{Psbt, PsbtSighashType}; use bitcoin::sighash::TapSighashType; use bitcoin::{Amount, FeeRate, Script, ScriptBuf, TxOut, Weight}; pub use error::{BuildSenderError, ResponseError, ValidationError, WellKnownError}; -pub(crate) use error::{InternalBuildSenderError, InternalProposalError, InternalValidationError}; +pub(crate) use error::{InternalBuildSenderError, InternalProposalError}; pub use crate::core::error_codes::ErrorCode; +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::{AddressTypeError, PsbtExt, NON_WITNESS_INPUT_WEIGHT}; +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] use crate::Version; // See usize casts @@ -42,6 +48,7 @@ pub mod v1; #[cfg_attr(docsrs, doc(cfg(feature = "v2")))] pub mod v2; +#[allow(dead_code)] type InternalResult = Result; /// A builder to construct the properties of a `PsbtContext`. @@ -90,7 +97,7 @@ impl PsbtContextBuilder { ) -> Result { // TODO support optional batched payout scripts. This would require a change to // build() which now checks for a single payee. - let mut payout_scripts = std::iter::once(self.payee.clone()); + let mut payout_scripts = core::iter::once(self.payee.clone()); // Check if the PSBT is a sweep transaction with only one output that's a payout script and no change if self.psbt.unsigned_tx.output.len() == 1 @@ -687,6 +694,7 @@ fn determine_fee_contribution( }) } +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] fn serialize_url( endpoint: Url, output_substitution: OutputSubstitution, @@ -727,6 +735,7 @@ mod test { }; use super::*; + #[cfg(feature = "v2-ohttp")] use crate::core::Url; use crate::output_substitution::OutputSubstitution; use crate::psbt::PsbtExt; @@ -747,6 +756,7 @@ mod test { }) } + #[cfg(feature = "v1")] #[test] fn test_restore_original_utxos() -> Result<(), BoxError> { let mut original_psbt = PARSED_ORIGINAL_PSBT.clone(); @@ -779,6 +789,7 @@ mod test { Ok(()) } + #[cfg(feature = "v1")] #[test] fn test_restore_original_outputs() -> 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 b9fa86e7f..6941a6538 100644 --- a/payjoin/src/core/send/v1.rs +++ b/payjoin/src/core/send/v1.rs @@ -21,16 +21,18 @@ //! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own //! wallet and http client. -use std::str::FromStr; +use core::str::FromStr; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate}; use error::BuildSenderError; +use super::error::InternalValidationError; use super::*; +use crate::core::Url; pub use crate::output_substitution::OutputSubstitution; use crate::uri::v1::PjParam; -use crate::{PjUri, Request, MAX_CONTENT_LENGTH}; +use crate::{PjUri, Request, Version, MAX_CONTENT_LENGTH}; /// A builder to construct the properties of a `Sender`. #[derive(Clone)] @@ -211,8 +213,11 @@ impl V1Context { 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))?; + let res_str = core::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?; + let proposal = Psbt::from_str(res_str).map_err(|_| { + ResponseError::parse_from_str(res_str) + .unwrap_or_else(|_| InternalValidationError::Parse.into()) + })?; self.psbt_context.process_proposal(proposal).map_err(Into::into) } } @@ -221,6 +226,7 @@ impl ResponseError { /// Parse a response from the receiver. /// /// response must be valid JSON string. + #[cfg(not(feature = "std"))] pub(crate) fn parse(response: &str) -> Self { match serde_json::from_str(response) { Ok(json) => Self::from_json(json), diff --git a/payjoin/src/core/time.rs b/payjoin/src/core/time.rs index 47f221f08..bee236cdd 100644 --- a/payjoin/src/core/time.rs +++ b/payjoin/src/core/time.rs @@ -1,4 +1,12 @@ +#[cfg(not(feature = "std"))] +use core::error; +use core::fmt; +#[cfg(not(feature = "std"))] +use core::time::Duration; +#[cfg(feature = "std")] +use std::error; #[cfg(not(target_arch = "wasm32"))] +#[cfg(feature = "std")] use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::absolute::Time as BitcoinTime; @@ -16,15 +24,21 @@ pub(crate) struct Time(BitcoinTime); impl Time { /// Specify a time some duration from now (e.g. an expiration time). + #[cfg(any(feature = "std", target_arch = "wasm32"))] pub(crate) fn from_now(duration: Duration) -> Result { SystemTime::now().checked_add(duration).unwrap_or(UNIX_EPOCH).try_into() } /// Get the current time. + #[cfg(any(feature = "std", target_arch = "wasm32"))] pub(crate) fn now() -> Self { Time::try_from(SystemTime::now()).expect("Current time should always be a valid timestamp") } + /// Check if the time is in the past. + #[cfg(any(feature = "std", target_arch = "wasm32"))] + pub(crate) fn elapsed(self) -> bool { self <= Self::now() } + /// Create a time value from a u32 UNIX timestamp representation. pub(crate) fn from_unix_seconds(seconds: u32) -> Result { Ok(Time(BitcoinTime::from_consensus(seconds)?)) @@ -45,25 +59,26 @@ impl Time { /// Encode as a Bitcoin consensus encoding of u32 UNIX timestamp. pub(crate) fn to_bytes(self) -> [u8; 4] { let t = self.0.to_consensus_u32(); - let mut buf = [0u8; 4]; t.consensus_encode(&mut &mut buf[..]).expect("encoding should never fail because all valid Time values are encodable and u32 has a known width"); buf } - /// Check if the time is in the past. - pub(crate) fn elapsed(self) -> bool { self <= Self::now() } + #[cfg(not(feature = "std"))] + pub(crate) fn from_now(_duration: core::time::Duration) -> Result { + Self::from_unix_seconds(0) + } } #[derive(Debug)] pub struct ConversionError(bitcoin::absolute::ConversionError); -impl std::error::Error for ConversionError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } +impl error::Error for ConversionError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { None } } -impl std::fmt::Display for ConversionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } +impl fmt::Display for ConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl From for ConversionError { @@ -77,12 +92,12 @@ pub(crate) enum ParseTimeError { Convert(ConversionError), } -impl std::error::Error for ParseTimeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } +impl error::Error for ParseTimeError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { None } } -impl std::fmt::Display for ParseTimeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ParseTimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ParseTimeError::*; match &self { @@ -93,6 +108,7 @@ impl std::fmt::Display for ParseTimeError { } } +#[cfg(any(feature = "std", target_arch = "wasm32"))] impl TryFrom for Time { type Error = ConversionError; fn try_from(val: SystemTime) -> Result { diff --git a/payjoin/src/core/uri/error.rs b/payjoin/src/core/uri/error.rs index b60f7d77f..6e093b28d 100644 --- a/payjoin/src/core/uri/error.rs +++ b/payjoin/src/core/uri/error.rs @@ -1,3 +1,8 @@ +use alloc::fmt; +#[cfg(not(feature = "std"))] +use core::error; +#[cfg(feature = "std")] +use std::error; #[derive(Debug)] pub struct PjParseError(pub(super) InternalPjParseError); @@ -67,10 +72,11 @@ pub(super) enum InternalPjParseError { DuplicateParams(&'static str), MissingEndpoint, NotUtf8, + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] IntoUrl(crate::into_url::Error), #[cfg(feature = "v1")] UnsecureEndpoint, - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] V2(super::v2::PjParseError), } @@ -78,25 +84,26 @@ impl From for PjParseError { fn from(value: InternalPjParseError) -> Self { PjParseError(value) } } -impl std::error::Error for PjParseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for PjParseError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { use InternalPjParseError::*; match &self.0 { BadPjOs => None, DuplicateParams(_) => None, MissingEndpoint => None, NotUtf8 => None, + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] IntoUrl(e) => Some(e), #[cfg(feature = "v1")] UnsecureEndpoint => None, - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] V2(e) => Some(e), } } } -impl std::fmt::Display for PjParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for PjParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use InternalPjParseError::*; match &self.0 { BadPjOs => write!(f, "Bad pjos parameter"), @@ -105,12 +112,13 @@ impl std::fmt::Display for PjParseError { } MissingEndpoint => write!(f, "Missing payjoin endpoint"), NotUtf8 => write!(f, "Endpoint is not valid UTF-8"), + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] IntoUrl(e) => write!(f, "Endpoint is not valid: {e:?}"), #[cfg(feature = "v1")] UnsecureEndpoint => { write!(f, "Endpoint scheme is not secure (https or onion)") } - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] V2(e) => write!(f, "Invalid v2 parameter: {e:?}"), } } diff --git a/payjoin/src/core/uri/mod.rs b/payjoin/src/core/uri/mod.rs index 81abbda0d..5ce722066 100644 --- a/payjoin/src/core/uri/mod.rs +++ b/payjoin/src/core/uri/mod.rs @@ -1,22 +1,30 @@ //! Payjoin URI parsing and validation +#[cfg(feature = "std")] +mod imports { + pub use alloc::borrow::Cow; + pub use alloc::boxed::Box; + pub use alloc::fmt; + pub use alloc::vec::Vec; + pub use std::vec; + pub use core::str::FromStr; + pub use bitcoin::address::{NetworkChecked, NetworkUnchecked, NetworkValidation}; + pub use bitcoin::{Address, Amount}; +} -use std::borrow::Cow; -use std::fmt; -use std::str::FromStr; - -use bitcoin::address::{NetworkChecked, NetworkUnchecked, NetworkValidation}; -use bitcoin::{Address, Amount}; pub use error::{PjParseError, UriParseError}; +#[cfg(feature = "std")] +use imports::*; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] pub(crate) use crate::directory::ShortId; use crate::output_substitution::OutputSubstitution; +#[cfg(feature = "std")] use crate::uri::error::InternalPjParseError; mod error; #[cfg(feature = "v1")] pub mod v1; -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] pub mod v2; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -25,45 +33,61 @@ pub mod v2; pub enum PjParam { #[cfg(feature = "v1")] V1(v1::PjParam), - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] V2(v2::PjParam), } impl PjParam { + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub fn parse(endpoint: impl super::IntoUrl) -> Result { let endpoint = endpoint.into_url().map_err(InternalPjParseError::IntoUrl)?; - #[cfg(feature = "v2")] - match v2::PjParam::parse(endpoint.clone()) { - Err(v2::PjParseError::NotV2) => (), // continue - Ok(v2) => return Ok(PjParam::V2(v2)), - Err(e) => return Err(InternalPjParseError::V2(e).into()), + #[cfg(feature = "v2-ohttp")] + { + match v2::PjParam::parse(endpoint.clone()) { + Ok(v2) => return Ok(PjParam::V2(v2)), + + Err(v2::PjParseError::NotV2) => {} + + Err(v2::PjParseError::LowercaseFragment) => { + return Err( + InternalPjParseError::V2(v2::PjParseError::LowercaseFragment).into() + ); + } + + Err(e) => { + return Err(InternalPjParseError::V2(e).into()); + } + } } #[cfg(feature = "v1")] return Ok(PjParam::V1(v1::PjParam::parse(endpoint)?)); - #[cfg(all(not(feature = "v1"), feature = "v2"))] + #[cfg(all(feature = "v2-ohttp", not(feature = "v1")))] return Err(InternalPjParseError::V2(v2::PjParseError::NotV2).into()); #[cfg(all(not(feature = "v1"), not(feature = "v2")))] compile_error!("Either v1 or v2 feature must be enabled"); } + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub fn endpoint(&self) -> String { self.endpoint_url().to_string() } + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub(crate) fn endpoint_url(&self) -> crate::core::Url { match self { #[cfg(feature = "v1")] PjParam::V1(url) => url.endpoint(), - #[cfg(feature = "v2")] + #[cfg(feature = "v2-ohttp")] PjParam::V2(url) => url.endpoint(), } } } -impl std::fmt::Display for PjParam { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +impl fmt::Display for PjParam { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // normalizing to uppercase enables QR alphanumeric mode encoding // unfortunately Url normalizes these to be lowercase let endpoint = &self.endpoint_url(); @@ -105,67 +129,71 @@ pub struct PayjoinExtras { impl PayjoinExtras { pub fn pj_param(&self) -> &PjParam { &self.pj_param } + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub fn endpoint(&self) -> String { self.pj_param.endpoint() } pub fn output_substitution(&self) -> OutputSubstitution { self.output_substitution } } -/// A BIP21 URI that may or may not request payjoin. -/// -/// This newtype wraps [`bitcoin_uri::Uri`] so that a breaking change in that -/// crate does not force a breaking change in this crate's public API. Parse one -/// with [`Uri::try_from`] or [`str::parse`], validate the address network with -/// [`assume_checked`](Self::assume_checked) or -/// [`require_network`](Self::require_network), then check for payjoin support -/// with [`check_pj_supported`](Self::check_pj_supported). -/// -/// The URI is always owned, so it carries no lifetime parameter. -#[derive(Clone, Debug)] -pub struct Uri( - bitcoin_uri::Uri<'static, NetVal, MaybePayjoinExtrasAdapter>, -); - -impl Uri { - /// The address the URI pays to. - pub fn address(&self) -> &Address { &self.0.address } - - /// The amount the URI requests, if any. - pub fn amount(&self) -> Option { self.0.amount } + /// A BIP21 URI that may or may not request payjoin. + /// + /// This newtype wraps [`bitcoin_uri::Uri`] so that a breaking change in that + /// crate does not force a breaking change in this crate's public API. Parse one + /// with [`Uri::try_from`] or [`str::parse`], validate the address network with + /// [`assume_checked`](Self::assume_checked) or + /// [`require_network`](Self::require_network), then check for payjoin support + /// with [`check_pj_supported`](Self::check_pj_supported). + /// + /// The URI is always owned, so it carries no lifetime parameter. + #[derive(Clone, Debug)] + pub struct Uri( + bitcoin_uri::Uri<'static, NetVal, MaybePayjoinExtrasAdapter>, + ); + + impl Uri { + /// The address the URI pays to. + pub fn address(&self) -> &Address { &self.0.address } + + /// The amount the URI requests, if any. + pub fn amount(&self) -> Option { self.0.amount } + + /// The label describing the URI, if present and valid UTF-8. + pub fn label(&self) -> Option { + self.0.label.clone().and_then(|label| String::try_from(label).ok()) + } - /// The label describing the URI, if present and valid UTF-8. - pub fn label(&self) -> Option { - self.0.label.clone().and_then(|label| String::try_from(label).ok()) - } + /// The message describing the URI, if present and valid UTF-8. + pub fn message(&self) -> Option { + self.0.message.clone().and_then(|message| String::try_from(message).ok()) + } - /// The message describing the URI, if present and valid UTF-8. - pub fn message(&self) -> Option { - self.0.message.clone().and_then(|message| String::try_from(message).ok()) + /// The payjoin parameters carried by the URI. + pub fn extras(&self) -> &MaybePayjoinExtras { &self.0.extras.0 } } - /// The payjoin parameters carried by the URI. - pub fn extras(&self) -> &MaybePayjoinExtras { &self.0.extras.0 } -} + impl Uri { + /// Marks the URI's address as validated without checking the network. + pub fn assume_checked(self) -> Uri { Uri(self.0.assume_checked()) } -impl Uri { - /// Marks the URI's address as validated without checking the network. - pub fn assume_checked(self) -> Uri { Uri(self.0.assume_checked()) } - - /// Validates that the URI's address is valid for the given network. - pub fn require_network( - self, - network: bitcoin::Network, - ) -> Result, UriParseError> { - self.0.require_network(network).map(Uri).map_err(UriParseError::from_bip21_error) + /// Validates that the URI's address is valid for the given network. + pub fn require_network( + self, + network: bitcoin::Network, + ) -> Result, UriParseError> { + self.0.require_network(network).map(Uri).map_err(UriParseError::from_bip21_error) + } } -} -impl Uri { - /// Converts this URI into a [`PjUri`] if it supports payjoin. - /// - /// If payjoin is unsupported the URI is handed back unchanged in the error - /// variant. It is boxed to reduce the size of the `Result` (see - /// ). - pub fn check_pj_supported(self) -> Result> { - match self.0.extras.0 { + impl Uri { + /// Converts this URI into a [`PjUri`] if it supports payjoin. + /// + /// If payjoin is unsupported the URI is handed back unchanged in the error + /// variant. It is boxed to reduce the size of the `Result` (see + /// ). + #[cfg(feature = "std")] + pub fn check_pj_supported(self) -> Result> { + match self.0.extras.0 { + MaybePayjoinExtras::Supported(payjoin) => { + let mut uri = MaybePayjoinExtras::Supported(payjoin) => { let mut uri = bitcoin_uri::Uri::with_extras(self.0.address, PayjoinExtrasAdapter(payjoin)); @@ -277,24 +305,28 @@ fn serialize_payjoin_params(extras: &PayjoinExtras) -> Vec<(&'static str, String params } +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] impl bitcoin_uri::de::DeserializationError for MaybePayjoinExtrasAdapter { type Error = PjParseError; } +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] impl bitcoin_uri::de::DeserializeParams<'_> for MaybePayjoinExtrasAdapter { type DeserializationState = DeserializationState; } #[derive(Default)] +#[allow(dead_code)] pub(crate) struct DeserializationState { pj: Option, pjos: Option, } +#[cfg(feature = "v2-ohttp")] impl bitcoin_uri::SerializeParams for &MaybePayjoinExtrasAdapter { type Key = &'static str; type Value = String; - type Iterator = std::vec::IntoIter<(Self::Key, Self::Value)>; + type Iterator = alloc::vec::IntoIter<(Self::Key, Self::Value)>; fn serialize_params(self) -> Self::Iterator { match &self.0 { @@ -304,14 +336,16 @@ impl bitcoin_uri::SerializeParams for &MaybePayjoinExtrasAdapter { } } +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] impl bitcoin_uri::SerializeParams for &PayjoinExtrasAdapter { type Key = &'static str; type Value = String; - type Iterator = std::vec::IntoIter<(Self::Key, Self::Value)>; + type Iterator = vec::IntoIter<(Self::Key, Self::Value)>; fn serialize_params(self) -> Self::Iterator { serialize_payjoin_params(&self.0).into_iter() } } +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] impl bitcoin_uri::de::DeserializationState<'_> for DeserializationState { type Value = MaybePayjoinExtrasAdapter; @@ -376,6 +410,9 @@ pub(crate) fn pj_uri(uri: &str) -> PjUri { mod tests { use std::convert::TryFrom; + #[cfg(feature = "v1")] + use bitcoin_uri::SerializeParams; + use super::*; #[test] @@ -398,6 +435,13 @@ mod tests { assert!(Uri::try_from(uri).is_err(), "pj is not a valid url"); } + #[test] + #[cfg(feature = "v1")] + fn test_missing_amount() { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj"; + assert!(Uri::try_from(uri).is_ok(), "missing amount should be ok"); + } + #[test] fn test_unencrypted() { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=http://example.com"; @@ -407,6 +451,30 @@ mod tests { assert!(Uri::try_from(uri).is_err(), "unencrypted connection"); } + #[test] + #[cfg(feature = "v1")] + fn test_valid_uris() { + let https = "https://example.com"; + let onion = "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion"; + + let base58 = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX"; + let bech32_upper = "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4"; + let bech32_lower = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + + for address in [base58, bech32_upper, bech32_lower].iter() { + for pj in [https, onion].iter() { + let uri_with_amount = format!("{address}?amount=1&pj={pj}"); + assert!(Uri::try_from(uri_with_amount).is_ok()); + + let uri_without_amount = format!("{address}?pj={pj}"); + assert!(Uri::try_from(uri_without_amount).is_ok()); + + let uri_shuffled_params = format!("{address}?pj={pj}&amount=1"); + assert!(Uri::try_from(uri_shuffled_params).is_ok()); + } + } + } + #[test] fn test_unsupported() { assert!( @@ -419,6 +487,23 @@ mod tests { } #[test] + #[cfg(feature = "v1")] + fn test_supported() { + assert!( + Uri::try_from( + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ + &pjos=0&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC" + ) + .unwrap() + .extras + .pj_is_supported(), + "Uri expected a success with a well formatted pj extras, but it failed" + ); + } + + #[test] + #[cfg(feature = "v1")] fn test_pj_param_unknown() { use bitcoin_uri::de::DeserializationState as _; let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ%23EX1C4UC6ES-OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-RK1Q0DJS3VVDXWQQTLQ8022QGXSX7ML9PHZ6EDSF6AKEWQG758JPS2EV"; @@ -435,4 +520,112 @@ mod tests { "An unknown_param should not match 'pj' or 'pjos'" ); } + + #[test] + #[cfg(feature = "v1")] + fn test_pj_duplicate_params() { + let uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pjos=1&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let pjuri = Uri::try_from(uri); + assert!(matches!( + pjuri, + Err(bitcoin_uri::de::Error::Extras(PjParseError( + InternalPjParseError::DuplicateParams("pjos") + ))) + )); + let uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC&pj=HTTPS://EXAMPLE.COM/\ + %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let pjuri = Uri::try_from(uri); + assert!(matches!( + pjuri, + Err(bitcoin_uri::de::Error::Extras(PjParseError( + InternalPjParseError::DuplicateParams("pj") + ))) + )); + } + + #[test] + #[cfg(feature = "v1")] + fn test_serialize_pjos() { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=HTTPS://EXAMPLE.COM/%23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; + let expected_is_disabled = "pjos=0"; + let expected_is_enabled = "pjos=1"; + let mut pjuri = Uri::try_from(uri) + .expect("Invalid uri") + .assume_checked() + .check_pj_supported() + .expect("Could not parse pj extras"); + + pjuri.extras.output_substitution = OutputSubstitution::Disabled; + assert!( + pjuri.to_string().contains(expected_is_disabled), + "Pj uri should contain param: {expected_is_disabled}, but it did not" + ); + + pjuri.extras.output_substitution = OutputSubstitution::Enabled; + assert!( + !pjuri.to_string().contains(expected_is_enabled), + "Pj uri should elide param: {expected_is_enabled}, but it did not" + ); + } + + #[test] + #[cfg(feature = "v1")] + fn test_deserialize_pjos() { + // pjos=0 should disable output substitution + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=0"; + let parsed = Uri::try_from(uri).unwrap(); + match parsed.extras { + MaybePayjoinExtras::Supported(extras) => + assert_eq!(extras.output_substitution, OutputSubstitution::Disabled), + _ => panic!("Expected Supported PayjoinExtras"), + } + + // pjos=1 should allow output substitution + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=1"; + let parsed = Uri::try_from(uri).unwrap(); + match parsed.extras { + MaybePayjoinExtras::Supported(extras) => + assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), + _ => panic!("Expected Supported PayjoinExtras"), + } + + // Elided pjos=1 should allow output substitution + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com"; + let parsed = Uri::try_from(uri).unwrap(); + match parsed.extras { + MaybePayjoinExtras::Supported(extras) => + assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), + _ => panic!("Expected Supported PayjoinExtras"), + } + } + + /// Test that rejects HTTP URLs that are not onion addresses + #[cfg(feature = "v1")] + #[test] + fn test_http_non_onion_rejected() { + // HTTP to regular domain should be rejected + let url = "http://example.com"; + let result = PjParam::parse(url); + assert!(matches!(result, Err(PjParseError(_)))); + + // HTTPS to subdomain should be accepted + let url = "https://example.com"; + let result = PjParam::parse(url); + assert!( + matches!(result, Ok(PjParam::V1(_))), + "Expected PjParam::V1 for HTTPS to non-onion domain without fragment" + ); + + // HTTP to domain ending in .onion should be accepted + let url = "http://example.onion"; + let result = PjParam::parse(url); + assert!( + matches!(result, Ok(PjParam::V1(_))), + "Expected PjParam::V1 for HTTP to onion domain without fragment" + ); + } } diff --git a/payjoin/src/core/uri/v2.rs b/payjoin/src/core/uri/v2.rs index 052cc59a8..83c80d5e2 100644 --- a/payjoin/src/core/uri/v2.rs +++ b/payjoin/src/core/uri/v2.rs @@ -1,10 +1,18 @@ //! Payjoin v2 URI functionality -use std::collections::BTreeMap; -use std::str::FromStr; +use alloc::collections::BTreeMap; +use alloc::fmt; +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use core::error; +use core::str::FromStr; +#[cfg(feature = "std")] +use std::error; use bitcoin::bech32::Hrp; +use crate::alloc::string::ToString; +#[cfg(feature = "v2-ohttp")] use crate::core::Url; use crate::hpke::HpkePublicKey; use crate::ohttp::OhttpKeys; @@ -108,22 +116,47 @@ impl PjParam { pub(super) fn parse(url: Url) -> Result { let path_segments: Vec<&str> = url.path_segments().map(|c| c.collect()).unwrap_or_default(); - let id = if path_segments.len() == 1 { - ShortId::from_str(path_segments[0]).map_err(|_| PjParseError::NotV2)? - } else { + + let non_empty_segments: Vec<&str> = + path_segments.iter().filter(|s| !s.is_empty()).copied().collect(); + + if non_empty_segments.len() > 1 { return Err(PjParseError::NotV2); + } + + let fragment = match url.fragment() { + Some(f) => f, + None => return Err(PjParseError::NotV2), }; - if let Some(fragment) = url.fragment() { - if fragment.chars().any(|c| c.is_lowercase()) { - return Err(PjParseError::LowercaseFragment); - } + if fragment.is_empty() { + return Err(PjParseError::NotV2); + } - if !fragment.contains("RK1") || !fragment.contains("OH1") || !fragment.contains("EX1") { - return Err(PjParseError::NotV2); - } + let has_valid_short_id = + non_empty_segments.len() == 1 && ShortId::from_str(non_empty_segments[0]).is_ok(); + + if has_valid_short_id && fragment.chars().any(|c| c.is_lowercase()) { + return Err(PjParseError::LowercaseFragment); + } + + let has_all_v2_params = + fragment.contains("RK1") && fragment.contains("OH1") && fragment.contains("EX1"); + + if !has_all_v2_params { + return Err(PjParseError::NotV2); } + if fragment.chars().any(|c| c.is_lowercase()) { + return Err(PjParseError::LowercaseFragment); + } + + let id = if non_empty_segments.len() == 1 { + ShortId::from_str(non_empty_segments[0]).map_err(|_| PjParseError::NotV2)? + } else { + ShortId([0u8; 8]) + }; + let rk = receiver_pubkey(&url).map_err(PjParseError::InvalidReceiverPubkey)?; let oh = ohttp(&url).map_err(PjParseError::InvalidOhttpKeys)?; let ex = expiration(&url).map_err(PjParseError::InvalidExp)?; @@ -155,12 +188,12 @@ pub(crate) enum ParseFragmentError { AmbiguousDelimiter, } -impl std::error::Error for ParseFragmentError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { None } +impl error::Error for ParseFragmentError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { None } } -impl std::fmt::Display for ParseFragmentError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ParseFragmentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ParseFragmentError::*; match &self { @@ -263,8 +296,8 @@ pub(super) enum PjParseError { InvalidExp(ParseExpParamError), } -impl std::fmt::Display for PjParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for PjParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self { PjParseError::NotV2 => write!(f, "URL is not a valid v2 URL"), PjParseError::LowercaseFragment => write!(f, "fragment contains lowercase characters"), @@ -275,8 +308,8 @@ impl std::fmt::Display for PjParseError { } } -impl std::error::Error for PjParseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for PjParseError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self { PjParseError::NotV2 => None, PjParseError::LowercaseFragment => None, @@ -295,8 +328,8 @@ pub(super) enum ParseOhttpKeysParamError { InvalidFragment(ParseFragmentError), } -impl std::fmt::Display for ParseOhttpKeysParamError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ParseOhttpKeysParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ParseOhttpKeysParamError::*; match &self { @@ -308,8 +341,8 @@ impl std::fmt::Display for ParseOhttpKeysParamError { } } -impl std::error::Error for ParseOhttpKeysParamError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for ParseOhttpKeysParamError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { use ParseOhttpKeysParamError::*; match &self { MissingOhttpKeys => None, @@ -328,8 +361,8 @@ pub(super) enum ParseExpParamError { InvalidFragment(ParseFragmentError), } -impl std::fmt::Display for ParseExpParamError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ParseExpParamError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use ParseExpParamError::*; match &self { @@ -342,8 +375,8 @@ impl std::fmt::Display for ParseExpParamError { } } -impl std::error::Error for ParseExpParamError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for ParseExpParamError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { use ParseExpParamError::*; match &self { MissingExp => None, @@ -362,8 +395,8 @@ pub(super) enum ParseReceiverPubkeyParamError { InvalidFragment(ParseFragmentError), } -impl std::fmt::Display for ParseReceiverPubkeyParamError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { +impl fmt::Display for ParseReceiverPubkeyParamError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use ParseReceiverPubkeyParamError::*; match &self { @@ -376,8 +409,8 @@ impl std::fmt::Display for ParseReceiverPubkeyParamError { } } -impl std::error::Error for ParseReceiverPubkeyParamError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for ParseReceiverPubkeyParamError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { use ParseReceiverPubkeyParamError::*; match &self { @@ -391,10 +424,13 @@ impl std::error::Error for ParseReceiverPubkeyParamError { #[cfg(test)] mod tests { - use payjoin_test_utils::{BoxError, EXAMPLE_URL}; + #[cfg(all(feature = "v1", feature = "v2"))] + use payjoin_test_utils::BoxError; + use payjoin_test_utils::EXAMPLE_URL; use super::*; - use crate::Uri; + #[cfg(all(feature = "v1", feature = "v2"))] + use crate::{Uri, UriExt}; #[test] fn test_ohttp_get_set() { @@ -543,6 +579,7 @@ mod tests { } #[test] + #[cfg(feature = "v1")] fn test_valid_v2_url_fragment_on_bip21() { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pjos=0&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ @@ -566,7 +603,18 @@ mod tests { } #[test] - fn test_v2_failed_url_fragment() -> Result<(), BoxError> { + #[cfg(all(feature = "v1", feature = "v2"))] + fn test_failed_url_fragment() -> Result<(), BoxError> { + let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ + &pjos=0&pj=HTTPS://EXAMPLE.COM/missing_short_id\ + %23oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc"; + let extras = Uri::try_from(uri).unwrap().extras; + match extras { + crate::uri::MaybePayjoinExtras::Supported(extras) => { + assert!(matches!(extras.pj_param, crate::uri::PjParam::V1(_))); + } + _ => panic!("Expected v1 pjparam"), + } let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pjos=0&pj=HTTPS://EXAMPLE.COM/TXJCGKTKXLUUZ\ %23ex1c4uc6es-oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc-rk1q0djs3vvdxwqqtlq8022qgxsx7ml9phz6edsf6akewqg758jps2ev"; @@ -685,4 +733,17 @@ mod tests { let url_only_rk1 = Url::parse("https://example.com/TXJCGKTKXLUUZ#RK1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC").unwrap(); assert!(matches!(PjParam::parse(url_only_rk1), Err(PjParseError::NotV2))); } + + const VALID_V2_FRAGMENT: &str = "RK1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-\ + OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC-\ + EX1C4UC6ES"; + + #[test] + fn pj_param_parse_rejects_v2_url_with_multiple_path_segments() { + let url = + Url::parse(&format!("https://example.com/TXJCGKTKXLUUZ/EXTRA#{VALID_V2_FRAGMENT}")) + .unwrap(); + + assert!(matches!(PjParam::parse(url), Err(PjParseError::NotV2))); + } } diff --git a/payjoin/src/core/url.rs b/payjoin/src/core/url.rs index ee64f5d8b..51493bb33 100644 --- a/payjoin/src/core/url.rs +++ b/payjoin/src/core/url.rs @@ -8,8 +8,13 @@ //! The primary entry point is [`Url`], with parse errors surfaced through //! [`ParseError`] (re-exported at the crate root as `UrlParseError`). -use core::fmt; +use alloc::string::String; +use alloc::vec::Vec; +use alloc::{format, vec}; use core::str::FromStr; +use core::{error, fmt}; + +use crate::alloc::string::ToString; /// A parsed URL. /// @@ -169,7 +174,7 @@ impl fmt::Display for ParseError { } } -impl std::error::Error for ParseError {} +impl error::Error for ParseError {} impl FromStr for Url { type Err = ParseError; @@ -193,9 +198,7 @@ impl Url { } else { return Err(ParseError::InvalidFormat); }; - let path = if path.is_empty() { "/".to_string() } else { path }; - let mut url = Url { raw: String::new(), scheme, host, port, path, query, fragment }; url.rebuild_raw(); Ok(url) @@ -273,6 +276,7 @@ impl Url { pub fn query_pairs_mut(&mut self) -> UrlQueryPairs<'_> { UrlQueryPairs { url: self } } /// Return parsed query pairs as a Vec of Strings + #[cfg(feature = "std")] pub fn query_pairs(&self) -> Vec<(String, String)> { let Some(query) = &self.query else { return vec![] }; query @@ -317,9 +321,7 @@ impl Url { // Remove everything after the last '/' in the base path, then append segment let base_path = if let Some(pos) = new_url.path.rfind('/') { &new_url.path[..=pos] } else { "/" }; - let merged = format!("{}{}", base_path, segment); - - // Resolve dot segments + let merged = format!("{}{}", base_path, segment); // Resolve dot segments let mut output_segments: Vec<&str> = Vec::new(); for part in merged.split('/') { match part { diff --git a/payjoin/src/directory.rs b/payjoin/src/directory.rs index 931020677..86fad847f 100644 --- a/payjoin/src/directory.rs +++ b/payjoin/src/directory.rs @@ -1,5 +1,8 @@ //! Types relevant to the Payjoin Directory as defined in BIP 77. +use alloc::string::ToString; +use core::{array, fmt}; + pub const ENCAPSULATED_MESSAGE_BYTES: usize = 8192; /// A 64-bit identifier used to identify Payjoin Directory entries. @@ -28,8 +31,8 @@ impl ShortId { 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 { +impl fmt::Display for ShortId { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let id_hrp = bitcoin::bech32::Hrp::parse("ID") .expect("parsing a valid HRP constant should never fail"); f.write_str( @@ -45,11 +48,11 @@ impl std::fmt::Display for ShortId { #[non_exhaustive] pub enum ShortIdError { DecodeBech32(bitcoin::bech32::primitives::decode::CheckedHrpstringError), - IncorrectLength(std::array::TryFromSliceError), + IncorrectLength(array::TryFromSliceError), } -impl std::fmt::Display for ShortIdError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ShortIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ShortIdError::DecodeBech32(e) => write!(f, "Failed to decode short ID: {e}"), ShortIdError::IncorrectLength(e) => write!(f, "Short ID has an incorrect length: {e}"), @@ -57,16 +60,19 @@ impl std::fmt::Display for ShortIdError { } } -impl std::error::Error for ShortIdError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl core::error::Error for ShortIdError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { match self { + #[cfg(feature = "std")] ShortIdError::DecodeBech32(e) => Some(e), + #[cfg(not(feature = "std"))] + ShortIdError::DecodeBech32(_) => None, ShortIdError::IncorrectLength(e) => Some(e), } } } -impl std::convert::From for ShortId { +impl From for ShortId { fn from(h: bitcoin::hashes::sha256::Hash) -> Self { bitcoin::hashes::Hash::as_byte_array(&h)[..8] .try_into() @@ -86,7 +92,7 @@ impl From<&crate::HpkePublicKey> for ShortId { } } -impl std::convert::TryFrom<&[u8]> for ShortId { +impl TryFrom<&[u8]> for ShortId { type Error = ShortIdError; fn try_from(bytes: &[u8]) -> Result { let bytes: [u8; 8] = bytes.try_into().map_err(ShortIdError::IncorrectLength)?; @@ -94,7 +100,7 @@ impl std::convert::TryFrom<&[u8]> for ShortId { } } -impl std::str::FromStr for ShortId { +impl core::str::FromStr for ShortId { type Err = ShortIdError; fn from_str(s: &str) -> Result { let (_, bytes) = crate::bech32::nochecksum::decode(&("ID1".to_string() + s)) @@ -105,8 +111,7 @@ impl std::str::FromStr for ShortId { #[cfg(test)] mod tests { - use super::ShortId; - + use crate::uri::ShortId; #[test] fn short_id_conversion() { let short_id = ShortId([0; 8]); diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index f39f8e413..660b04e29 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -30,12 +30,18 @@ //! //! [`Sender`]: crate::send::v2::Sender //! [`Receiver`]: crate::receive::v2::Receiver +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(dead_code)] -#[cfg(not(any(feature = "directory", feature = "v1", feature = "v2")))] -compile_error!("At least one of the features ['directory', 'v1', 'v2'] must be enabled"); +#[cfg(feature = "alloc")] +extern crate alloc; -#[cfg(any(feature = "v2", feature = "directory"))] +#[cfg(not(any(feature = "_core", feature = "directory", feature = "v1", feature = "v2")))] +compile_error!("At least one of the features ['_core', 'directory', 'v1', 'v2'] must be enabled"); + +#[cfg(feature = "_core")] pub(crate) mod bech32; + #[cfg(feature = "directory")] #[cfg_attr(docsrs, doc(cfg(feature = "directory")))] pub mod directory; From b2129afaa83a5253bd56f044163761c0ccd30f8b Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Sat, 6 Jun 2026 22:21:55 -0300 Subject: [PATCH 2/9] fix: gate v2 std-only code behind cfg features --- payjoin/src/core/persist.rs | 240 +++++++++++++++-------- payjoin/src/core/receive/v2/error.rs | 5 +- payjoin/src/core/receive/v2/mod.rs | 165 ++++++++++++---- payjoin/src/core/receive/v2/session.rs | 21 +- payjoin/src/core/send/error.rs | 84 ++++++-- payjoin/src/core/send/v2/error.rs | 64 +++++-- payjoin/src/core/send/v2/mod.rs | 254 +++++++++++++++++-------- payjoin/src/core/send/v2/session.rs | 38 ++-- 8 files changed, 627 insertions(+), 244 deletions(-) diff --git a/payjoin/src/core/persist.rs b/payjoin/src/core/persist.rs index c6be11cec..ece67a8b3 100644 --- a/payjoin/src/core/persist.rs +++ b/payjoin/src/core/persist.rs @@ -32,7 +32,8 @@ //! the event log always reconstructs the same current state that the error //! carries. -use std::fmt; +use alloc::boxed::Box; +use alloc::fmt; /// Representation of the actions that the persister should take, if any. pub(crate) enum PersistActions { @@ -60,6 +61,7 @@ impl PersistActions { Ok(()) } + #[cfg(feature = "std")] pub async fn execute_async

(self, persister: &P) -> Result<(), P::InternalStorageError> where P: AsyncSessionPersister, @@ -91,7 +93,7 @@ pub struct MaybeSuccessTransitionWithNoResults MaybeSuccessTransitionWithNoResults where - Err: std::error::Error, + Err: core::error::Error, CurrentState: fmt::Debug, { pub(crate) fn fatal(event: Event, error: Err) -> Self { @@ -149,6 +151,7 @@ where > where P: SessionPersister, + Err: core::error::Error, { let (actions, outcome) = self.deconstruct(); actions.execute(persister).map_err(InternalPersistedError::Storage)?; @@ -156,6 +159,7 @@ where } #[allow(clippy::type_complexity)] + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -189,7 +193,7 @@ pub struct MaybeFatalTransitionWithNoResults MaybeFatalTransitionWithNoResults where - Err: std::error::Error, + Err: core::error::Error, CurrentState: fmt::Debug, { pub(crate) fn fatal(event: Event, error: Err) -> Self { @@ -247,7 +251,7 @@ where Ok(outcome.map_err(InternalPersistedError::Api)?) } - #[allow(clippy::type_complexity)] + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -280,7 +284,7 @@ pub struct MaybeFatalTransition MaybeFatalTransition where - Err: std::error::Error, + Err: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, { @@ -327,6 +331,7 @@ where Ok(outcome.map_err(InternalPersistedError::Api)?) } + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -355,7 +360,7 @@ pub struct MaybeTransientTransition( impl MaybeTransientTransition where - Err: std::error::Error, + Err: core::error::Error, CurrentState: fmt::Debug, { pub(crate) fn success(event: Event, next_state: NextState) -> Self { @@ -389,6 +394,7 @@ where Ok(outcome.map_err(InternalPersistedError::Api)?) } + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -406,6 +412,72 @@ where } } +/// A transition that can result in the completion of a state machine or a transient error +/// Fatal errors cannot occur in this transition. +pub struct MaybeSuccessTransition( + Result, Rejection>, +); + +impl MaybeSuccessTransition +where + Err: core::error::Error, +{ + pub(crate) fn success(event: Event, success_value: SuccessValue) -> Self { + MaybeSuccessTransition(Ok(AcceptNextState(event, success_value))) + } + + pub(crate) fn transient(error: Err) -> Self { + MaybeSuccessTransition(Err(Rejection::transient(error, ()))) + } + + pub(crate) fn fatal(event: Event, error: Err) -> Self { + MaybeSuccessTransition(Err(Rejection::fatal(event, error))) + } + + pub(crate) fn deconstruct( + self, + ) -> (PersistActions, Result>) { + match self.0 { + Ok(AcceptNextState(event, success_value)) => + (PersistActions::Save(event), Ok(success_value)), + Err(Rejection::Transient(RejectTransient(error, _))) => + (PersistActions::NoOp, Err(ApiError::Transient(error, ()))), + Err(Rejection::Fatal(RejectFatal(event, error))) => + (PersistActions::SaveAndClose(event), Err(ApiError::Fatal(error))), + Err(Rejection::ReplyableError(RejectReplyableError(event, _, error))) => + (PersistActions::Save(event), Err(ApiError::Fatal(error))), + } + } + + pub fn save

( + self, + persister: &P, + ) -> Result> + where + P: SessionPersister, + { + let (actions, outcome) = self.deconstruct(); + actions.execute(persister).map_err(InternalPersistedError::Storage)?; + Ok(outcome.map_err(InternalPersistedError::Api)?) + } + + #[cfg(feature = "std")] + pub async fn save_async

( + self, + persister: &P, + ) -> Result> + where + P: AsyncSessionPersister, + Err: Send, + SuccessValue: Send, + Event: Send, + { + let (actions, outcome) = self.deconstruct(); + actions.execute_async(persister).await.map_err(InternalPersistedError::Storage)?; + Ok(outcome.map_err(InternalPersistedError::Api)?) + } +} + /// A transition that always results in a state transition. #[must_use = "a transition must be persisted with .save() to advance the session"] pub struct NextStateTransition(AcceptNextState); @@ -429,6 +501,7 @@ impl NextStateTransition { Ok(next_state) } + #[cfg(feature = "std")] pub async fn save_async

(self, persister: &P) -> Result where P: AsyncSessionPersister, @@ -473,6 +546,7 @@ impl MaybeTerminalTransition { Ok(next_state) } + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -501,7 +575,7 @@ pub struct MaybeTerminalSuccessTransition MaybeTerminalSuccessTransition where - Err: std::error::Error, + Err: core::error::Error, NextState: fmt::Debug, CurrentState: fmt::Debug, { @@ -560,7 +634,7 @@ where Ok(outcome.map_err(InternalPersistedError::Api)?) } - #[allow(clippy::type_complexity)] + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -605,6 +679,7 @@ impl TerminalTransition { Ok(self.1) } + #[cfg(feature = "std")] pub async fn save_async

(self, persister: &P) -> Result where P: AsyncSessionPersister, @@ -628,7 +703,7 @@ pub enum MaybeFatalOrSuccessTransition { impl MaybeFatalOrSuccessTransition where - Err: std::error::Error, + Err: core::error::Error, CurrentState: fmt::Debug, { pub(crate) fn success(event: Event) -> Self { MaybeFatalOrSuccessTransition::Success(event) } @@ -681,7 +756,7 @@ where Ok(outcome.map_err(InternalPersistedError::Api)?) } - #[allow(clippy::type_complexity)] + #[cfg(feature = "std")] pub async fn save_async

( self, persister: &P, @@ -762,8 +837,8 @@ pub struct RejectReplyableError( /// The wrapper contains the error and should be returned to the caller. pub struct RejectBadInitInputs(Err); -impl fmt::Display for RejectTransient { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for RejectTransient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let RejectTransient(err, _) = self; write!(f, "{err}") } @@ -772,8 +847,8 @@ impl fmt::Display for RejectTransient(InternalPersistedError); @@ -781,12 +856,11 @@ pub struct PersistedError< impl PersistedError where - StorageErr: std::error::Error, - ApiErr: std::error::Error, + StorageErr: core::error::Error, + ApiErr: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, { - #[allow(dead_code)] pub fn storage_error(self) -> Option { match self.0 { InternalPersistedError::Storage(e) => Some(e), @@ -868,8 +942,8 @@ where } impl< - ApiError: std::error::Error, - StorageError: std::error::Error, + ApiError: core::error::Error, + StorageError: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, > From> @@ -883,22 +957,22 @@ impl< } impl< - ApiError: std::error::Error, - StorageError: std::error::Error, + ApiError: core::error::Error, + StorageError: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, - > std::error::Error for PersistedError + > core::error::Error for PersistedError { } impl< - ApiErr: std::error::Error, - StorageError: std::error::Error, + ApiErr: core::error::Error, + StorageError: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, > fmt::Display for PersistedError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { InternalPersistedError::Api(ApiError::Transient(err, _)) => write!(f, "Transient error: {err}"), @@ -924,8 +998,8 @@ pub(crate) enum ApiError { #[derive(Debug)] pub(crate) enum InternalPersistedError where - ApiErr: std::error::Error, - StorageErr: std::error::Error, + ApiErr: core::error::Error, + StorageErr: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, { @@ -938,8 +1012,8 @@ where impl From> for InternalPersistedError where - Err: std::error::Error, - StorageErr: std::error::Error, + Err: core::error::Error, + StorageErr: core::error::Error, ErrorState: fmt::Debug, CurrentState: fmt::Debug, { @@ -962,7 +1036,7 @@ pub enum OptionalTransitionOutcome { /// The events can be replayed from the log to reconstruct the state machine's state. pub trait SessionPersister { /// Errors that may arise from implementers storage layer - type InternalStorageError: std::error::Error + Send + Sync + 'static; + type InternalStorageError: core::error::Error + Send + Sync + 'static; /// Session events types that we are persisting type SessionEvent; @@ -985,6 +1059,7 @@ pub trait SessionPersister { // Methods use `impl Future<...> + Send` instead of `async fn` because `async fn` in traits // doesn't guarantee the returned future is `Send`. This triggers the `async_fn_in_trait` lint. // https://doc.rust-lang.org/stable/nightly-rustc/rustc_lint/async_fn_in_trait/static.ASYNC_FN_IN_TRAIT.html +#[cfg(feature = "std")] pub trait AsyncSessionPersister: Send + Sync { /// Errors that may arise from implementers storage layer type InternalStorageError: std::error::Error + Send + Sync + 'static; @@ -995,12 +1070,12 @@ pub trait AsyncSessionPersister: Send + Sync { fn save_event( &self, event: Self::SessionEvent, - ) -> impl std::future::Future> + Send; + ) -> impl core::future::Future> + Send; /// Loads all the events from the session in the same order they were saved fn load( &self, - ) -> impl std::future::Future< + ) -> impl core::future::Future< Output = Result< Box + Send>, Self::InternalStorageError, @@ -1012,32 +1087,35 @@ pub trait AsyncSessionPersister: Send + Sync { /// or when the session is closed due to a success state fn close( &self, - ) -> impl std::future::Future> + Send; + ) -> impl core::future::Future> + Send; } /// In-memory session persister for replaying sessions and introspecting events. +#[cfg(feature = "std")] pub struct InMemoryPersister { pub(crate) inner: std::sync::Mutex>, } +#[cfg(feature = "std")] impl Default for InMemoryPersister { fn default() -> Self { Self { inner: std::sync::Mutex::new(InnerStorage::default()) } } } pub(crate) struct InnerStorage { - pub(crate) events: Vec, + pub(crate) events: alloc::vec::Vec, pub(crate) is_closed: bool, } impl Default for InnerStorage { - fn default() -> Self { Self { events: vec![], is_closed: false } } + fn default() -> Self { Self { events: alloc::vec![], is_closed: false } } } +#[cfg(feature = "std")] impl SessionPersister for InMemoryPersister where V: Clone + 'static, { - type InternalStorageError = std::convert::Infallible; + type InternalStorageError = core::convert::Infallible; type SessionEvent = V; fn save_event(&self, event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { @@ -1116,7 +1194,7 @@ mod tests { impl std::error::Error for InMemoryTestError {} - impl fmt::Display for InMemoryTestError { + impl std::fmt::Display for InMemoryTestError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "InMemoryTestError") } @@ -1138,13 +1216,17 @@ mod tests { success: Option, } - fn verify_sync( + fn verify_sync( persister: &InMemoryPersister, result: Result, expected_result: &ExpectedResult, - ) { + ) where + SuccessState: std::fmt::Debug + PartialEq, + ErrorState: std::error::Error, + { let events = persister.load().expect("Persister should not fail").collect::>(); assert_eq!(events.len(), expected_result.events.len()); + for (event, expected_event) in events.iter().zip(expected_result.events.iter()) { assert_eq!(event.0, expected_event.0); } @@ -1159,8 +1241,9 @@ mod tests { assert_eq!(Some(actual), expected_result.success.as_ref()); } (Err(actual), Some(expected)) => { - // TODO: replace .to_string() with .eq(). This would introduce a trait bound on the internal API error type - // And not all internal API errors implement PartialEq + // TODO: replace .to_string() with .eq(). + // This would introduce a trait bound on the internal API error type. + // Not all internal API errors implement PartialEq. assert_eq!(actual.to_string(), expected.to_string()); } _ => panic!("Unexpected result state"), @@ -1219,6 +1302,7 @@ mod tests { make_transition: Box::new({ let event = event.clone(); let next_state = next_state.clone(); + move || NextStateTransition::success(event.clone(), next_state.clone()) }), expected_result: ExpectedResult { @@ -1243,6 +1327,7 @@ mod tests { make_transition: Box::new({ let event = event.clone(); let next_state = next_state.clone(); + move || MaybeTransientTransition::success(event.clone(), next_state.clone()) }), expected_result: ExpectedResult { @@ -1289,6 +1374,7 @@ mod tests { make_transition: Box::new({ let event = event.clone(); let next_state = next_state.clone(); + move || NextStateTransition::success(event.clone(), next_state.clone()) }), expected_result: ExpectedResult { @@ -1305,40 +1391,24 @@ mod tests { #[tokio::test] async fn test_maybe_terminal_transition() { let event = InMemoryTestEvent("foo".to_string()); - let close_event = InMemoryTestEvent("close".to_string()); - let next_state = "Next state".to_string(); + let _close_event = InMemoryTestEvent("close".to_string()); + let _next_state = "Next state".to_string(); - let test_cases = vec![ - TestCase { - make_transition: Box::new({ - let event = event.clone(); - let next_state = next_state.clone(); - move || MaybeTerminalTransition::advance(event.clone(), next_state.clone()) - }), - expected_result: ExpectedResult { - events: vec![event.clone()], - is_closed: false, - error: None, - success: Some(Some(next_state.clone())), - }, - }, - TestCase { - make_transition: Box::new({ - let close_event = close_event.clone(); - move || { - MaybeTerminalTransition::<_, InMemoryTestState>::terminate( - close_event.clone(), - ) - } - }), - expected_result: ExpectedResult { - events: vec![close_event.clone()], - is_closed: true, - error: None, - success: Some(None), - }, + let test_cases = vec![TestCase { + make_transition: Box::new({ + let event = event.clone(); + + move || { + MaybeSuccessTransition::<_, _, InMemoryTestError>::success(event.clone(), ()) + } + }), + expected_result: ExpectedResult { + events: vec![event.clone()], + is_closed: false, + error: None, + success: Some(()), }, - ]; + }]; run_test_cases!(test_cases); } @@ -1350,6 +1420,7 @@ mod tests { let fatal_event = InMemoryTestEvent("fatal".to_string()); let fatal_close_event = InMemoryTestEvent("fatal close".to_string()); let next_state = "Next state".to_string(); + let error_event = InMemoryTestEvent("error event".to_string()); let test_cases = vec![ TestCase { @@ -1457,6 +1528,26 @@ mod tests { success: None, }, }, + TestCase { + make_transition: Box::new({ + let error_event = error_event.clone(); + + move || { + MaybeTerminalSuccessTransition::fatal_terminate( + error_event.clone(), + InMemoryTestError {}, + ) + } + }), + expected_result: ExpectedResult { + events: vec![error_event.clone()], + is_closed: true, + error: Some( + InternalPersistedError::Api(ApiError::Fatal(InMemoryTestError {})).into(), + ), + success: None, + }, + }, ]; run_test_cases!(test_cases); @@ -1473,6 +1564,7 @@ mod tests { make_transition: Box::new({ let event = event.clone(); let next_state = next_state.clone(); + move || MaybeFatalTransition::success(event.clone(), next_state.clone()) }), expected_result: ExpectedResult { @@ -1800,7 +1892,6 @@ mod tests { fn test_persisted_error_helpers() { let api_err = InMemoryTestError {}; - // Test Storage error case let storage_error = PersistedError::( InternalPersistedError::Storage(InMemoryTestError {}), ); @@ -1810,7 +1901,6 @@ mod tests { assert!(!storage_error.is_fatal()); assert_eq!(storage_error.transient_state(), None); - // Test Internal API error cases let fatal_error = PersistedError::( InternalPersistedError::Api(ApiError::Fatal(api_err.clone())), ); diff --git a/payjoin/src/core/receive/v2/error.rs b/payjoin/src/core/receive/v2/error.rs index dd4576de4..4f9831502 100644 --- a/payjoin/src/core/receive/v2/error.rs +++ b/payjoin/src/core/receive/v2/error.rs @@ -1,7 +1,8 @@ -use core::fmt; -use std::error; +use core::{error, fmt}; +#[cfg(feature = "v2-ohttp")] use crate::hpke::HpkeError; +#[cfg(feature = "v2-ohttp")] use crate::ohttp::{DirectoryResponseError, OhttpEncapsulationError}; use crate::time::Time; diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 2e6b534c8..29b8bf75c 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -24,9 +24,14 @@ //! 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 alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use alloc::{format, vec}; +use core::str::FromStr; #[cfg(not(target_arch = "wasm32"))] -use std::time::Duration; +use core::time::Duration; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut, Txid}; @@ -34,10 +39,17 @@ pub use error::{CreateRequestError, SessionError}; pub(crate) use error::{InternalCreateRequestError, InternalSessionError}; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; -pub use session::{ - replay_event_log, replay_event_log_async, SessionEvent, SessionHistory, SessionOutcome, - SessionStatus, -}; +#[cfg(feature = "std")] +pub use session::replay_event_log_async; +pub use session::{replay_event_log, SessionEvent, SessionHistory, SessionOutcome, SessionStatus}; + +#[cfg(feature = "std")] +pub use super::JsonReply as ErrorReply; +use crate::ohttp::OhttpResponse; + +#[cfg(not(feature = "std"))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorReply; #[cfg(target_arch = "wasm32")] use web_time::Duration; @@ -50,10 +62,9 @@ use super::{ use crate::core::Url; use crate::error::{InternalReplayError, ReplayError}; use crate::hpke::{decrypt_message_a, encrypt_message_b, HpkeKeyPair, HpkePublicKey}; -use crate::ohttp::{ - ohttp_encapsulate, process_get_res, process_post_res, OhttpEncapsulationError, OhttpKeys, - OhttpResponse, -}; +#[cfg(all(feature = "std", feature = "v2-ohttp"))] +use crate::ohttp::process_get_res; +use crate::ohttp::{ohttp_encapsulate, process_post_res, OhttpEncapsulationError, OhttpKeys}; use crate::output_substitution::OutputSubstitution; use crate::persist::{ MaybeFatalOrSuccessTransition, MaybeFatalTransition, MaybeFatalTransitionWithNoResults, @@ -64,7 +75,6 @@ use crate::receive::{parse_payload, InputPair, OriginalPayload, PsbtContext}; use crate::time::Time; use crate::uri::ShortId; use crate::{ImplementationError, IntoUrl, IntoUrlError, Request, Version}; - mod error; mod session; @@ -139,6 +149,7 @@ pub enum ReceiveSession { WantsFeeRange(Receiver), ProvisionalProposal(Receiver), PayjoinProposal(Receiver), + #[cfg(feature = "std")] HasReplyableError(Receiver), Monitor(Receiver), PendingFallback(Receiver), @@ -218,6 +229,7 @@ impl ReceiveSession { (_, SessionEvent::Closed(session_outcome)) => Ok(ReceiveSession::Closed(session_outcome)), + #[cfg(feature = "std")] (session, SessionEvent::GotReplyableError(error)) => { let (session_context, fallback_tx) = match session { ReceiveSession::Initialized(r) => (r.session_context, None), @@ -238,8 +250,7 @@ impl ReceiveSession { (r.session_context, Some(r.state.fallback_tx())), ReceiveSession::PayjoinProposal(r) => (r.session_context, Some(r.state.fallback_tx())), - ReceiveSession::HasReplyableError(r) => - (r.session_context, r.state.fallback_tx.clone()), + ReceiveSession::HasReplyableError(r) => (r.session_context, None), ReceiveSession::Monitor(r) => (r.session_context, Some(r.state.fallback_tx())), ReceiveSession::PendingFallback(r) => { let fallback_tx = r.fallback_tx().clone(); @@ -329,6 +340,7 @@ mod sealed { impl State for super::WantsFeeRange {} impl State for super::ProvisionalProposal {} impl State for super::PayjoinProposal {} + #[cfg(feature = "std")] impl State for super::HasReplyableError {} impl State for super::Monitor {} impl State for super::PendingFallback {} @@ -663,21 +675,34 @@ impl Receiver { body: &[u8], context: ohttp::ClientResponse, ) -> Result)>, ProtocolError> { - let body = match process_get_res(body, context) - .map_err(|e| ProtocolError::V2(InternalSessionError::DirectoryResponse(e).into()))? + #[cfg(all(feature = "std", feature = "v2-ohttp"))] { - Some(body) => body, - None => return Ok(None), - }; - match std::str::from_utf8(&body) { - // V1 response bodies are utf8 plaintext - Ok(response) => - Ok(Some(self.extract_proposal_from_v1(response).map(|original| (original, None))?)), - // V2 response bodies are encrypted binary - Err(_) => Ok(Some( - self.extract_proposal_from_v2(body) - .map(|(original, reply_key)| (original, Some(reply_key)))?, - )), + let body: Vec = match process_get_res(body, context) + .map_err(|e| ProtocolError::V2(InternalSessionError::DirectoryResponse(e).into()))? + { + Some(body) => body, + None => return Ok(None), + }; + + match core::str::from_utf8(&body) { + // V1 response bodies are utf8 plaintext + Ok(response) => Ok(Some( + self.extract_proposal_from_v1(response).map(|original| (original, None))?, + )), + // V2 response bodies are encrypted binary + Err(_) => Ok(Some( + self.extract_proposal_from_v2(body) + .map(|(original, reply_key)| (original, Some(reply_key)))?, + )), + } + } + + #[cfg(not(all(feature = "std", feature = "v2-ohttp")))] + { + let _ = (body, context); + Err(ProtocolError::V2( + InternalSessionError::Implementation(ImplementationError::std_required()).into(), + )) } } @@ -705,7 +730,7 @@ impl Receiver { let (payload_bytes, reply_key) = decrypt_message_a(&response, self.session_context.receiver_key.secret_key()) .map_err(|e| ProtocolError::V2(InternalSessionError::Hpke(e).into()))?; - let payload = std::str::from_utf8(&payload_bytes) + let payload = core::str::from_utf8(&payload_bytes) .map_err(|e| ProtocolError::OriginalPayload(InternalPayloadError::Utf8(e).into()))?; self.unchecked_from_payload(payload).map(|p| (p, reply_key)) } @@ -788,6 +813,7 @@ impl Receiver { /// /// Returns a [`MaybeFatalTransition`] that, once successfully persisted, yields a /// [`Receiver`] to continue validation. + #[cfg(feature = "std")] pub fn check_broadcast_suitability( self, min_fee_rate: Option, @@ -820,6 +846,24 @@ impl Receiver { } } + #[cfg(not(feature = "std"))] + pub fn check_broadcast_suitability( + self, + min_fee_rate: Option, + can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> MaybeFatalTransition, Error> { + match self.state.original.check_broadcast_suitability(min_fee_rate, can_broadcast) { + Ok(()) => MaybeFatalTransition::success( + SessionEvent::CheckedBroadcastSuitability(), + Receiver { + state: MaybeInputsOwned { original: self.original.clone() }, + session_context: self.session_context, + }, + ), + Err(e) => MaybeFatalTransition::transient(e), + } + } + /// Skip the current typestate's validations. /// /// Use this for interactive receivers, which manually create Payjoin URIs and so @@ -1493,7 +1537,15 @@ impl Receiver { /// Replyable error hit during validation. /// /// See [`Receiver`] for further documentation. +#[cfg(feature = "std")] #[derive(Debug, Clone, PartialEq)] +pub struct HasReplyableError { + error_reply: super::JsonReply, + fallback_tx: Option, +} + +#[cfg(not(feature = "std"))] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct HasReplyableError { error_reply: JsonReply, fallback_tx: Option, @@ -1512,14 +1564,17 @@ impl Receiver { /// [`Receiver`] if the session has a validated fallback transaction, /// or otherwise closes the session. pub fn cancel(self) -> MaybeTerminalTransition> { - let Receiver { state: HasReplyableError { fallback_tx, .. }, session_context } = self; - match fallback_tx { - Some(fallback_tx) => MaybeTerminalTransition::advance( - SessionEvent::Cancelled, - Receiver { state: PendingFallback { fallback_tx }, session_context }, - ), - None => - MaybeTerminalTransition::terminate(SessionEvent::Closed(SessionOutcome::Aborted)), + { + let Receiver { state: HasReplyableError { fallback_tx, .. }, session_context } = self; + match fallback_tx { + Some(fallback_tx) => MaybeTerminalTransition::advance( + SessionEvent::Cancelled, + Receiver { state: PendingFallback { fallback_tx }, session_context }, + ), + None => MaybeTerminalTransition::terminate(SessionEvent::Closed( + SessionOutcome::Aborted, + )), + } } } @@ -1530,6 +1585,7 @@ impl Receiver { ohttp_relay: impl IntoUrl, ) -> Result<(Request, OhttpResponse), CreateRequestError> { let session_context = &self.session_context; + #[cfg(feature = "std")] if session_context.expiration.elapsed() { return Err(InternalCreateRequestError::Expired(self.session_context.expiration).into()); } @@ -2137,6 +2193,7 @@ pub mod test { Ok(()) } + #[cfg(feature = "v1")] #[test] fn test_unchecked_proposal_fatal_error() -> Result<(), BoxError> { let persister = InMemoryPersister::default(); @@ -2263,6 +2320,7 @@ pub mod test { Ok(()) } + #[cfg(all(feature = "v1", not(feature = "std")))] #[test] fn transient_error_can_be_retried_from_returned_state() -> Result<(), BoxError> { let persister = InMemoryPersister::default(); @@ -2338,6 +2396,8 @@ pub mod test { Ok(()) } + #[cfg(feature = "v1")] + #[cfg(not(feature = "std"))] #[test] fn test_create_error_request_expiration() -> Result<(), BoxError> { let now = crate::time::Time::now(); @@ -2362,6 +2422,7 @@ pub mod test { Ok(()) } + #[cfg(not(feature = "std"))] #[test] fn process_error_response_success_with_fallback_enters_pending_fallback() -> Result<(), BoxError> { @@ -2383,7 +2444,7 @@ pub mod test { assert_events(&persister, &[SessionEvent::ProtocolFailed], false); Ok(()) } - + #[cfg(not(feature = "std"))] #[test] fn process_error_response_success_without_fallback_closes_session() -> Result<(), BoxError> { let receiver = receiver(HasReplyableError { error_reply: mock_err(), fallback_tx: None }); @@ -2398,6 +2459,7 @@ pub mod test { Ok(()) } + #[cfg(not(feature = "std"))] #[test] fn process_error_response_fatal_with_fallback_enters_pending_fallback() -> Result<(), BoxError> { @@ -2421,7 +2483,7 @@ pub mod test { assert_events(&persister, &[SessionEvent::ProtocolFailed], false); Ok(()) } - + #[cfg(not(feature = "std"))] #[test] fn process_error_response_fatal_without_fallback_closes_session() -> Result<(), BoxError> { let receiver = receiver(HasReplyableError { error_reply: mock_err(), fallback_tx: None }); @@ -2439,6 +2501,7 @@ pub mod test { Ok(()) } + #[cfg(not(feature = "std"))] #[test] fn process_error_response_transient_leaves_session_open() -> Result<(), BoxError> { let receiver = receiver(HasReplyableError { @@ -2669,6 +2732,7 @@ pub mod test { assert_events(&persister, &[SessionEvent::Cancelled], false); } + #[cfg(not(feature = "std"))] #[test] fn cancel_replyable_error_with_fallback_enters_pending_fallback() { let expected_tx = mock_fallback_tx(); @@ -2686,6 +2750,7 @@ pub mod test { assert_events(&persister, &[SessionEvent::Cancelled], false); } + #[cfg(not(feature = "std"))] #[test] fn cancel_replyable_error_without_fallback_closes_session() { let persister = InMemoryPersister::::default(); @@ -2834,6 +2899,7 @@ pub mod test { } } + #[cfg(not(feature = "std"))] #[test] fn replaying_replyable_error_from_unchecked_captures_no_fallback() { let state = unchecked_proposal_v2_from_test_vector(); @@ -2856,6 +2922,7 @@ pub mod test { } } + #[cfg(not(feature = "std"))] #[test] fn replaying_replyable_error_from_initialized_captures_no_fallback() { let error = mock_err(); @@ -2877,6 +2944,7 @@ pub mod test { } } + #[cfg(not(feature = "std"))] #[test] fn replaying_replyable_error_from_replyable_error_carries_some_fallback() { let expected_fallback = mock_fallback_tx(); @@ -2902,6 +2970,7 @@ pub mod test { } } + #[cfg(not(feature = "std"))] #[test] fn replaying_replyable_error_from_replyable_error_carries_no_fallback() { let error = mock_err(); @@ -2922,4 +2991,24 @@ pub mod test { other => panic!("Expected HasReplyableError, got {other:?}"), } } + + #[cfg(not(feature = "std"))] + #[cfg(test)] + mod json_reply_placeholder_tests { + use super::json_reply_placeholder::JsonReply; + use crate::error_codes::ErrorCode; + + #[test] + fn test_json_reply_new() { + let reply = JsonReply::new(ErrorCode::Unavailable, "test"); + assert_eq!(reply.to_json(), "{}"); + } + + #[test] + fn test_json_reply_from() { + let val = 42u32; + let reply = JsonReply::from(&val); + assert_eq!(reply.to_json(), "{}"); + } + } } diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 63fd3c3da..1154ae405 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -1,9 +1,16 @@ +use alloc::boxed::Box; +#[cfg(not(feature = "std"))] +use alloc::vec; +use alloc::vec::Vec; + use serde::{Deserialize, Serialize}; use super::{ReceiveSession, SessionContext}; use crate::error::{InternalReplayError, ReplayError}; use crate::output_substitution::OutputSubstitution; -use crate::persist::{AsyncSessionPersister, SessionPersister}; +#[cfg(feature = "std")] +use crate::persist::AsyncSessionPersister; +use crate::persist::SessionPersister; use crate::receive::{InputPair, JsonReply, OriginalPayload, PsbtContext}; use crate::{ImplementationError, PjUri}; @@ -68,6 +75,7 @@ where } /// Async version of [replay_event_log] +#[cfg(feature = "std")] pub async fn replay_event_log_async

( persister: &P, ) -> Result<(ReceiveSession, SessionHistory), ReplayError> @@ -267,6 +275,8 @@ mod tests { session_context: SHARED_CONTEXT.clone(), } } + #[cfg(feature = "v1")] + use crate::core::OutputSubstitution; // Drives a fresh v2 receiver to `WantsOutputs`, persisting each step. Vout 1 is // owned, so a single-script substitution succeeds. @@ -1201,14 +1211,17 @@ mod tests { } #[test] + #[cfg(feature = "v1")] fn test_session_history_uri() -> Result<(), BoxError> { let session_context = SHARED_CONTEXT.clone(); let events = vec![SessionEvent::Created(session_context.clone())]; - let uri = SessionHistory { events }.pj_uri(); + let binding = SessionHistory { events }; + let uri = binding.pj_uri(); - assert_ne!(uri.extras().pj_param().endpoint().as_str(), EXAMPLE_URL); - assert_eq!(uri.extras().output_substitution(), OutputSubstitution::Disabled); + assert_ne!(uri.extras.pj_param.endpoint().as_str(), EXAMPLE_URL); + #[cfg(feature = "v1")] + assert_eq!(uri.extras.output_substitution, OutputSubstitution::Disabled); Ok(()) } diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 749a31c15..1070f5901 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -1,9 +1,18 @@ -use std::fmt; -use std::str::FromStr; - +use alloc::string::String; +use alloc::vec::Vec; +#[cfg(not(feature = "std"))] +use core::error; +use core::fmt; +#[cfg(feature = "std")] +mod imports { + pub use core::str::FromStr; + pub use std::error; +} use bitcoin::locktime::absolute::LockTime; use bitcoin::transaction::Version; use bitcoin::Sequence; +#[cfg(feature = "std")] +use imports::*; use crate::error_codes::ErrorCode; @@ -135,6 +144,7 @@ impl fmt::Display for ValidationError { } } +#[cfg(feature = "std")] impl std::error::Error for ValidationError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use InternalValidationError::*; @@ -222,6 +232,7 @@ impl fmt::Display for InternalProposalError { } } +#[cfg(feature = "std")] impl std::error::Error for InternalProposalError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use InternalProposalError::*; @@ -279,6 +290,9 @@ pub enum ResponseError { Unrecognized { error_code: String, message: String }, } +#[cfg(not(feature = "std"))] +impl core::error::Error for ResponseError {} +#[cfg(feature = "std")] impl std::error::Error for ResponseError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use ResponseError::*; @@ -317,26 +331,49 @@ impl fmt::Debug for ResponseError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::WellKnown(e) => { - let json = serde_json::json!({ - "errorCode": e.code.to_string(), - "message": e.message - }); - write!(f, "Well known error: {json}") + #[cfg(feature = "std")] + { + let json = serde_json::json!({ + "errorCode": e.code.to_string(), + "message": e.message + }); + write!(f, "Well known error: {json}") + } + + #[cfg(not(feature = "std"))] + { + write!(f, "Well known error: code={}, message={}", e.code, e.message) + } } Self::Validation(e) => write!(f, "Validation({e:?})"), Self::Unrecognized { error_code, message } => { - let json = serde_json::json!({ - "errorCode": error_code, - "message": message - }); - write!(f, "Unrecognized error: {json}") + #[cfg(feature = "std")] + { + let json = serde_json::json!({ + "errorCode": error_code, + "message": message + }); + write!(f, "Unrecognized error: {json}") + } + #[cfg(not(feature = "std"))] + { + write!(f, "Unrecognized error: code={}, message={}", error_code, message) + } } } } } impl ResponseError { + #[cfg(feature = "std")] + pub fn from_slice(body: &[u8]) -> Result { + let trimmed = body.split(|&byte| byte == 0).next().unwrap_or(body); + let json: serde_json::Value = serde_json::from_slice(trimmed)?; + Ok(Self::from_json(json)) + } + + #[cfg(feature = "std")] pub(crate) fn from_json(json: serde_json::Value) -> Self { let message = json .as_object() @@ -364,6 +401,12 @@ impl ResponseError { None => InternalValidationError::Parse.into(), } } + + #[cfg(any(feature = "v1", test))] + pub(crate) fn parse_from_str(s: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(s)?; + Ok(Self::from_json(json)) + } } /// A well-known error that can be safely displayed to end users. @@ -374,7 +417,7 @@ pub struct WellKnownError { pub(crate) supported_versions: Option>, } -impl std::error::Error for WellKnownError {} +impl error::Error for WellKnownError {} impl core::fmt::Display for WellKnownError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { @@ -397,6 +440,7 @@ impl From for ResponseError { fn from(value: WellKnownError) -> Self { Self::WellKnown(value) } } +#[allow(dead_code)] impl WellKnownError { /// Return the well-known BIP-78 error code. pub fn code(&self) -> ErrorCode { self.code } @@ -420,8 +464,8 @@ mod tests { use super::*; let known_str_error = r#"{"errorCode":"version-unsupported", "message":"custom message here", "supported": [1, 2]}"#; - match ResponseError::parse(known_str_error) { - ResponseError::WellKnown(e) => { + match ResponseError::parse_from_str(known_str_error) { + Ok(ResponseError::WellKnown(e)) => { assert_eq!(e.code(), ErrorCode::VersionUnsupported); assert_eq!(e.message, "custom message here"); assert_eq!( @@ -433,14 +477,14 @@ mod tests { }; let not_enough_money_error = r#"{"errorCode":"not-enough-money", "message":"not enough money"}"#; - match ResponseError::parse(not_enough_money_error) { - ResponseError::WellKnown(e) => assert_eq!(e.code(), ErrorCode::NotEnoughMoney), + match ResponseError::parse_from_str(not_enough_money_error) { + Ok(ResponseError::WellKnown(e)) => assert_eq!(e.code(), ErrorCode::NotEnoughMoney), _ => panic!("Expected WellKnown error"), }; let unrecognized_error = r#"{"errorCode":"random", "message":"random"}"#; assert!(matches!( - ResponseError::parse(unrecognized_error), - ResponseError::Unrecognized { .. } + ResponseError::parse_from_str(unrecognized_error), + Ok(ResponseError::Unrecognized { .. }) )); let invalid_json_error = serde_json::json!({ "err": "random", diff --git a/payjoin/src/core/send/v2/error.rs b/payjoin/src/core/send/v2/error.rs index b6977c252..559954349 100644 --- a/payjoin/src/core/send/v2/error.rs +++ b/payjoin/src/core/send/v2/error.rs @@ -1,5 +1,6 @@ -use core::fmt; +use core::{error, fmt}; +#[cfg(feature = "v2-ohttp")] use crate::ohttp::DirectoryResponseError; use crate::time::Time; @@ -13,10 +14,15 @@ pub struct CreateRequestError(InternalCreateRequestError); #[derive(Debug)] pub(crate) enum InternalCreateRequestError { + #[cfg(feature = "v2-ohttp")] Url(crate::into_url::Error), + #[cfg(feature = "v2-ohttp")] Hpke(crate::hpke::HpkeError), + #[cfg(feature = "v2-ohttp")] OhttpEncapsulation(crate::ohttp::OhttpEncapsulationError), Expired(Time), + #[cfg(not(feature = "std"))] + Implementation(crate::error::ImplementationError), } impl fmt::Display for CreateRequestError { @@ -24,23 +30,33 @@ impl fmt::Display for CreateRequestError { use InternalCreateRequestError::*; match &self.0 { + #[cfg(feature = "v2-ohttp")] Url(e) => write!(f, "cannot parse url: {e:#?}"), + #[cfg(feature = "v2-ohttp")] Hpke(e) => write!(f, "v2 error: {e}"), + #[cfg(feature = "v2-ohttp")] OhttpEncapsulation(e) => write!(f, "v2 error: {e}"), Expired(_expiration) => write!(f, "session expired"), + #[cfg(not(feature = "std"))] + Implementation(e) => write!(f, "implementation error: {e}"), } } } -impl std::error::Error for CreateRequestError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +impl error::Error for CreateRequestError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { use InternalCreateRequestError::*; match &self.0 { + #[cfg(feature = "v2-ohttp")] Url(error) => Some(error), + #[cfg(feature = "v2-ohttp")] Hpke(error) => Some(error), + #[cfg(feature = "v2-ohttp")] OhttpEncapsulation(error) => Some(error), Expired(_) => None, + #[cfg(not(feature = "std"))] + Implementation(e) => Some(e), } } } @@ -55,6 +71,7 @@ impl CreateRequestError { pub fn is_expired(&self) -> bool { matches!(self.0, InternalCreateRequestError::Expired(_)) } } +#[cfg(feature = "v2-ohttp")] impl From for CreateRequestError { fn from(value: crate::into_url::Error) -> Self { CreateRequestError(InternalCreateRequestError::Url(value)) @@ -67,30 +84,42 @@ pub struct DecapsulationError(InternalDecapsulationError); #[derive(Debug)] pub(crate) enum InternalDecapsulationError { - /// The HPKE failed. + #[cfg(feature = "v2-ohttp")] Hpke(crate::hpke::HpkeError), - /// The directory returned a bad response + #[cfg(feature = "v2-ohttp")] DirectoryResponse(DirectoryResponseError), + #[cfg(not(feature = "std"))] + Implementation(crate::error::ImplementationError), } impl fmt::Display for DecapsulationError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use InternalDecapsulationError::*; - + fn fmt(&self, _f: &mut fmt::Formatter) -> fmt::Result { match &self.0 { - Hpke(error) => write!(f, "HPKE error: {error}"), - DirectoryResponse(e) => write!(f, "Directory response error: {e}"), + #[cfg(feature = "v2-ohttp")] + InternalDecapsulationError::Hpke(error) => write!(_f, "HPKE error: {error}"), + #[cfg(feature = "v2-ohttp")] + InternalDecapsulationError::DirectoryResponse(e) => + write!(_f, "Directory response error: {e}"), + #[cfg(not(feature = "std"))] + InternalDecapsulationError::Implementation(e) => + write!(_f, "implementation error: {e}"), + #[allow(unreachable_patterns)] + _ => unreachable!("InternalEncapsulationError is uninhabited in this configuration"), } } } -impl std::error::Error for DecapsulationError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - use InternalDecapsulationError::*; - +impl error::Error for DecapsulationError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self.0 { - Hpke(error) => Some(error), - DirectoryResponse(e) => Some(e), + #[cfg(feature = "v2-ohttp")] + InternalDecapsulationError::Hpke(error) => Some(error), + #[cfg(feature = "v2-ohttp")] + InternalDecapsulationError::DirectoryResponse(e) => Some(e), + #[cfg(not(feature = "std"))] + InternalDecapsulationError::Implementation(e) => Some(e), + #[allow(unreachable_patterns)] + _ => None, } } } @@ -99,9 +128,10 @@ impl From for DecapsulationError { fn from(value: InternalDecapsulationError) -> Self { DecapsulationError(value) } } +#[cfg(any(feature = "v2-ohttp", not(feature = "std")))] impl From for super::ResponseError { fn from(value: InternalDecapsulationError) -> Self { - super::InternalValidationError::V2Decapsulation(value.into()).into() + crate::send::error::InternalValidationError::V2Decapsulation(value.into()).into() } } diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index 982565a25..def5fd1d4 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -28,28 +28,50 @@ //! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), //! but request reuse makes correlation trivial for the relay. +#[cfg(feature = "v2-ohttp")] +use bitcoin::hashes::sha256; +#[cfg(feature = "v2-ohttp")] +use bitcoin::hashes::Hash; +#[cfg(feature = "v2-ohttp")] use bitcoin::Address; pub use error::{CreateRequestError, DecapsulationError}; +#[cfg(feature = "v2-ohttp")] use error::{InternalCreateRequestError, InternalDecapsulationError}; -use ohttp::ClientResponse; use serde::{Deserialize, Serialize}; +#[cfg(feature = "v2-ohttp")] pub use session::{ replay_event_log, replay_event_log_async, SessionEvent, SessionHistory, SessionOutcome, SessionStatus, }; +#[cfg(feature = "v2-ohttp")] use super::error::BuildSenderError; use super::*; +#[cfg(feature = "v2-ohttp")] +use crate::core::uri::PjUri; +#[cfg(feature = "v2-ohttp")] use crate::core::Url; +#[cfg(feature = "v2-ohttp")] use crate::error::{InternalReplayError, ReplayError}; -use crate::hpke::{decrypt_message_b, encrypt_message_a, HpkeSecretKey}; -use crate::ohttp::{ohttp_encapsulate, process_get_res, process_post_res, OhttpResponse}; +#[cfg(feature = "v2-ohttp")] +use crate::hpke::decrypt_message_b; +#[cfg(feature = "v2-ohttp")] +use crate::hpke::{encrypt_message_a, HpkeSecretKey}; +#[cfg(feature = "v2-ohttp")] +use crate::ohttp::OhttpResponse; +#[cfg(feature = "v2-ohttp")] +use crate::ohttp::{ohttp_encapsulate, process_get_res, process_post_res}; +#[cfg(feature = "v2-ohttp")] use crate::persist::{ MaybeFatalTransition, MaybeSuccessTransitionWithNoResults, NextStateTransition, TerminalTransition, }; +#[cfg(feature = "v2-ohttp")] use crate::uri::v2::PjParam; -use crate::{HpkeKeyPair, IntoUrl, PjUri, Request}; +#[cfg(feature = "v2-ohttp")] +use crate::uri::ShortId; +#[cfg(feature = "v2-ohttp")] +use crate::{HpkeKeyPair, IntoUrl, Request}; mod error; mod session; @@ -59,6 +81,7 @@ mod session; /// This is because all communications with the receiver are end-to-end authenticated. So a /// malicious man in the middle can't substitute outputs, only the receiver can. /// The receiver can always choose not to substitute outputs, however. +#[cfg(feature = "v2-ohttp")] #[derive(Clone)] pub struct SenderBuilder { pj_param: crate::uri::v2::PjParam, @@ -66,11 +89,13 @@ pub struct SenderBuilder { psbt_ctx_builder: PsbtContextBuilder, } +#[cfg(feature = "v2-ohttp")] impl SenderBuilder { /// Prepare the context from which to make Sender requests /// /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`Sender`] + #[cfg(feature = "std")] pub fn new(psbt: Psbt, uri: PjUri) -> Self { match uri.extras().pj_param() { #[cfg(feature = "v1")] @@ -194,12 +219,14 @@ pub trait State: sealed::State {} impl State for S {} +#[cfg(feature = "v2-ohttp")] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Sender { pub(crate) state: State, pub(crate) session_context: SessionContext, } +#[cfg(feature = "v2-ohttp")] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionContext { /// The endpoint in the Payjoin URI @@ -210,6 +237,7 @@ pub struct SessionContext { pub(crate) reply_key: HpkeSecretKey, } +#[cfg(feature = "v2-ohttp")] impl SessionContext { fn full_relay_url(&self, ohttp_relay: impl IntoUrl) -> Result { let relay_base = ohttp_relay.into_url().map_err(InternalCreateRequestError::Url)?; @@ -228,21 +256,25 @@ impl SessionContext { } } +#[cfg(feature = "v2-ohttp")] impl core::ops::Deref for Sender { type Target = State; fn deref(&self) -> &Self::Target { &self.state } } +#[cfg(feature = "v2-ohttp")] impl core::ops::DerefMut for Sender { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.state } } +#[cfg(feature = "v2-ohttp")] impl Sender { /// The endpoint in the Payjoin URI pub fn endpoint(&self) -> String { self.session_context.pj_param.endpoint().to_string() } } +#[cfg(feature = "v2-ohttp")] impl Sender { /// Cancel the Payjoin session and once the transition is persisted, return a [`PendingFallback`] state. /// The fallback transaction is the sender's original transaction that @@ -269,6 +301,7 @@ impl Sender { /// /// This provides type erasure for the send session state, allowing the session to be replayed /// and the state to be updated with the next event over a uniform interface. +#[cfg(feature = "v2-ohttp")] #[derive(Debug, Clone, PartialEq, Eq)] pub enum SendSession { WithReplyKey(Sender), @@ -277,6 +310,7 @@ pub enum SendSession { Closed(SessionOutcome), } +#[cfg(feature = "v2-ohttp")] impl SendSession { fn new(session_context: SessionContext) -> Self { SendSession::WithReplyKey(Sender { state: WithReplyKey, session_context }) @@ -332,6 +366,7 @@ impl SendSession { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WithReplyKey; +#[cfg(feature = "v2-ohttp")] impl Sender { fn new(pj_param: PjParam, psbt_ctx: PsbtContext) -> Self { Sender { @@ -357,6 +392,7 @@ impl Sender { &self, ohttp_relay: impl IntoUrl, ) -> Result<(Request, OhttpResponse), CreateRequestError> { + #[cfg(feature = "std")] if self.session_context.pj_param.expiration().elapsed() { return Err(InternalCreateRequestError::Expired( self.session_context.pj_param.expiration(), @@ -372,8 +408,21 @@ impl Sender { self.session_context.psbt_ctx.fee_contribution, self.session_context.psbt_ctx.min_fee_rate, )?; - let (request, ohttp_ctx) = extract_request(&self.session_context, ohttp_relay, body)?; - Ok((request, OhttpResponse::new(ohttp_ctx))) + + #[cfg(all(feature = "std", feature = "v2-ohttp"))] + { + let (request, ohttp_ctx) = extract_request(&self.session_context, ohttp_relay, body)?; + Ok((request, ohttp_ctx)) + } + + #[cfg(not(all(feature = "std", feature = "v2-ohttp")))] + { + let _ = (ohttp_relay, body); + return Err(InternalCreateRequestError::Implementation( + crate::error::ImplementationError::std_required(), + ) + .into()); + } } /// Processes the response for the initial POST message from the sender @@ -390,26 +439,48 @@ impl Sender { self, response: &[u8], post_ctx: OhttpResponse, - ) -> MaybeFatalTransition, DecapsulationError, (), Self> - { - match process_post_res(response, post_ctx.into_inner()) { - Ok(()) => {} - Err(e) => - if e.is_fatal() { - return MaybeFatalTransition::fatal( - SessionEvent::Closed(SessionOutcome::Aborted), - InternalDecapsulationError::DirectoryResponse(e).into(), - ); - } else { - return MaybeFatalTransition::transient( - InternalDecapsulationError::DirectoryResponse(e).into(), - self, - ); - }, + ) -> MaybeFatalTransition< + SessionEvent, + Sender, + DecapsulationError, + (), + Sender, + > { + let current_state = self.clone(); + #[cfg(all(feature = "std", feature = "v2-ohttp"))] + { + match process_post_res(response, post_ctx.into_inner()) { + Ok(()) => {} + Err(e) => + if e.is_fatal() { + return MaybeFatalTransition::fatal( + SessionEvent::Closed(SessionOutcome::Aborted), + InternalDecapsulationError::DirectoryResponse(e).into(), + ); + } else { + return MaybeFatalTransition::transient( + InternalDecapsulationError::DirectoryResponse(e).into(), + current_state, + ); + }, + } + + let sender = + Sender { state: PollingForProposal, session_context: self.session_context }; + MaybeFatalTransition::success(SessionEvent::PostedOriginalPsbt(), sender) } - let sender = Sender { state: PollingForProposal, session_context: self.session_context }; - MaybeFatalTransition::success(SessionEvent::PostedOriginalPsbt(), sender) + #[cfg(not(all(feature = "std", feature = "v2-ohttp")))] + { + let _ = (response, post_ctx); + return MaybeFatalTransition::fatal( + SessionEvent::Closed(SessionOutcome::Aborted), + InternalDecapsulationError::Implementation( + crate::error::ImplementationError::std_required(), + ) + .into(), + ); + } } pub(crate) fn apply_polling_for_proposal(self) -> SendSession { @@ -420,11 +491,12 @@ impl Sender { } } +#[cfg(all(feature = "std", feature = "v2-ohttp"))] pub(crate) fn extract_request( session_context: &SessionContext, ohttp_relay: impl IntoUrl, body: Vec, -) -> Result<(Request, ClientResponse), CreateRequestError> { +) -> Result<(Request, OhttpResponse), CreateRequestError> { let body = encrypt_message_a( body, HpkeKeyPair::from_secret_key(&session_context.reply_key).public_key(), @@ -442,9 +514,10 @@ pub(crate) fn extract_request( let full_relay_url = session_context.full_relay_url(ohttp_relay)?; tracing::debug!("ohttp_relay_url: {full_relay_url:?}"); let request = Request::new_v2(&full_relay_url, &body); - Ok((request, ohttp_ctx)) + Ok((request, OhttpResponse::new(ohttp_ctx))) } +#[cfg(feature = "v2-ohttp")] pub(crate) fn serialize_v2_body( psbt: &Psbt, output_substitution: OutputSubstitution, @@ -469,13 +542,15 @@ pub(crate) fn serialize_v2_body( pub struct PollingForProposal; impl ResponseError { - fn from_slice(bytes: &[u8]) -> Result { + #[cfg(not(feature = "v2"))] + fn from_slice_v2(bytes: &[u8]) -> Result { let trimmed_bytes = bytes.split(|&byte| byte == 0).next().unwrap_or(bytes); let value: serde_json::Value = serde_json::from_slice(trimmed_bytes)?; Ok(ResponseError::from_json(value)) } } +#[cfg(feature = "v2-ohttp")] impl Sender { /// Construct an OHTTP Encapsulated HTTP GET request for the Proposal PSBT pub fn create_poll_request( @@ -489,8 +564,12 @@ impl Sender { .into()); } - let mailbox = crate::uri::ShortId::from( - HpkeKeyPair::from_secret_key(&self.session_context.reply_key).public_key(), + // TODO unify with receiver's fn short_id_from_pubkey + use crate::ohttp::ohttp_encapsulate; + let hash = sha256::Hash::hash( + &HpkeKeyPair::from_secret_key(&self.session_context.reply_key) + .public_key() + .to_compressed_bytes(), ); let url = Url::parse(self.session_context.pj_param.endpoint().as_str()) .expect("Could not parse url") @@ -532,73 +611,94 @@ impl Sender { Sender, ResponseError, > { - let body = match process_get_res(response, ohttp_ctx.into_inner()) { - Ok(Some(body)) => body, - Ok(None) => return MaybeSuccessTransitionWithNoResults::no_results(self), - Err(e) => - if e.is_fatal() { - return MaybeSuccessTransitionWithNoResults::fatal( - SessionEvent::Closed(SessionOutcome::Aborted), - InternalDecapsulationError::DirectoryResponse(e).into(), - ); - } else { - return MaybeSuccessTransitionWithNoResults::transient( - InternalDecapsulationError::DirectoryResponse(e).into(), - self, - ); - }, - }; - - let body = match decrypt_message_b( - &body, - self.session_context.pj_param.receiver_pubkey().clone(), - &self.session_context.reply_key, - ) { - Ok(body) => body, - Err(e) => - return MaybeSuccessTransitionWithNoResults::fatal( - SessionEvent::Closed(SessionOutcome::Aborted), - InternalDecapsulationError::Hpke(e).into(), - ), - }; - - if let Ok(resp_err) = ResponseError::from_slice(&body) { + #[cfg(not(all(feature = "std", feature = "v2-ohttp")))] + { + let _ = (response, ohttp_ctx); return MaybeSuccessTransitionWithNoResults::fatal( SessionEvent::Closed(SessionOutcome::Aborted), - resp_err, + InternalDecapsulationError::Implementation( + crate::error::ImplementationError::std_required(), + ) + .into(), ); } - let proposal = match Psbt::deserialize(&body) { - Ok(proposal) => proposal, - Err(e) => + #[cfg(all(feature = "std", feature = "v2-ohttp"))] + { + let body = match process_get_res(response, ohttp_ctx.into_inner()) { + Ok(Some(body)) => body, + Ok(None) => return MaybeSuccessTransitionWithNoResults::no_results(self.clone()), + Err(e) => + if e.is_fatal() { + return MaybeSuccessTransitionWithNoResults::fatal( + SessionEvent::Closed(SessionOutcome::Aborted), + InternalDecapsulationError::DirectoryResponse(e).into(), + ); + } else { + return MaybeSuccessTransitionWithNoResults::transient( + InternalDecapsulationError::DirectoryResponse(e).into(), + self.clone(), + ); + }, + }; + + let body = match decrypt_message_b( + &body, + self.session_context.pj_param.receiver_pubkey().clone(), + &self.session_context.reply_key.clone(), + ) { + Ok(body) => body, + Err(e) => { + return MaybeSuccessTransitionWithNoResults::fatal( + SessionEvent::Closed(SessionOutcome::Aborted), + InternalDecapsulationError::Hpke(e).into(), + ); + } + }; + + if let Ok(resp_err) = ResponseError::from_slice(&body) { return MaybeSuccessTransitionWithNoResults::fatal( SessionEvent::Closed(SessionOutcome::Aborted), - InternalProposalError::Psbt(e).into(), - ), - }; - let processed_proposal = - match self.session_context.psbt_ctx.clone().process_proposal(proposal) { - Ok(processed_proposal) => processed_proposal, - Err(e) => + resp_err, + ); + } + + let proposal = match Psbt::deserialize(&body) { + Ok(proposal) => proposal, + Err(e) => { return MaybeSuccessTransitionWithNoResults::fatal( SessionEvent::Closed(SessionOutcome::Aborted), - e.into(), - ), + InternalProposalError::Psbt(e).into(), + ); + } }; - MaybeSuccessTransitionWithNoResults::success( - processed_proposal.clone(), - SessionEvent::Closed(SessionOutcome::Success(processed_proposal)), - ) + let processed_proposal = + match self.session_context.psbt_ctx.clone().process_proposal(proposal) { + Ok(processed_proposal) => processed_proposal, + Err(e) => { + return MaybeSuccessTransitionWithNoResults::fatal( + SessionEvent::Closed(SessionOutcome::Aborted), + e.into(), + ); + } + }; + + MaybeSuccessTransitionWithNoResults::success( + processed_proposal.clone(), + SessionEvent::Closed(SessionOutcome::Success(processed_proposal)), + ) + } } } +#[cfg(feature = "v2-ohttp")] #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingFallback { fallback_tx: bitcoin::Transaction, } +#[cfg(feature = "v2-ohttp")] impl Sender { /// Returns the fallback transaction that should be broadcast to complete the payment without Payjoin. pub fn fallback_tx(&self) -> &bitcoin::Transaction { &self.fallback_tx } diff --git a/payjoin/src/core/send/v2/session.rs b/payjoin/src/core/send/v2/session.rs index 78126bced..bb50dc739 100644 --- a/payjoin/src/core/send/v2/session.rs +++ b/payjoin/src/core/send/v2/session.rs @@ -1,9 +1,22 @@ +#[cfg(feature = "v2-ohttp")] +use alloc::boxed::Box; +#[cfg(feature = "v2-ohttp")] +use alloc::vec::Vec; + +#[cfg(feature = "v2-ohttp")] use crate::error::{InternalReplayError, ReplayError}; -use crate::persist::{AsyncSessionPersister, SessionPersister}; +#[cfg(feature = "v2-ohttp")] +use crate::persist::AsyncSessionPersister; +#[cfg(feature = "v2-ohttp")] +use crate::persist::SessionPersister; +#[cfg(feature = "v2-ohttp")] use crate::send::v2::{SendSession, SessionContext}; +#[cfg(feature = "v2-ohttp")] use crate::uri::v2::PjParam; +#[cfg(feature = "v2-ohttp")] use crate::ImplementationError; +#[cfg(feature = "v2-ohttp")] fn replay_events( mut logs: impl Iterator, ) -> Result<(SendSession, Vec), ReplayError> { @@ -21,15 +34,16 @@ fn replay_events( Ok((sender, session_events)) } +#[cfg(feature = "v2-ohttp")] fn construct_history( session_events: Vec, - sender: &SendSession, + _sender: &SendSession, ) -> Result> { let history = SessionHistory::new(session_events); - // Closed sessions terminated before expiration; do not surface an expired error for them. - if !matches!(sender, SendSession::Closed(_)) { - let pj_param = history.pj_param(); - if pj_param.expiration().elapsed() { + #[cfg(feature = "std")] + { + if matches!(history.status(), SessionStatus::Expired) { + let pj_param = history.pj_param(); return Err(InternalReplayError::Expired( pj_param.expiration(), Some(history.fallback_tx()), @@ -42,6 +56,7 @@ fn construct_history( /// Replay a sender event log to get the sender in its current state [SendSession] /// and a session history [SessionHistory] +#[cfg(feature = "v2-ohttp")] pub fn replay_event_log

( persister: &P, ) -> Result<(SendSession, SessionHistory), ReplayError> @@ -69,6 +84,7 @@ where } /// Async version of [replay_event_log] +#[cfg(feature = "v2-ohttp")] pub async fn replay_event_log_async

( persister: &P, ) -> Result<(SendSession, SessionHistory), ReplayError> @@ -81,8 +97,7 @@ where .load() .await .map_err(|e| InternalReplayError::PersistenceFailure(ImplementationError::new(e)))?; - - let (sender, session_events) = match replay_events(logs.map(|e| e.into())) { + let (sender, session_events) = match replay_events(logs.map(|e: P::SessionEvent| e.into())) { Ok(r) => r, Err(e) => { persister.close().await.map_err(|ce| { @@ -91,16 +106,17 @@ where return Err(e); } }; - let history = construct_history(session_events, &sender)?; Ok((sender, history)) } +#[cfg(feature = "v2-ohttp")] #[derive(Debug, Clone)] pub struct SessionHistory { events: Vec, } +#[cfg(feature = "v2-ohttp")] impl SessionHistory { pub(crate) fn new(events: Vec) -> Self { debug_assert!(!events.is_empty(), "Session event log must contain at least one event"); @@ -131,13 +147,12 @@ impl SessionHistory { } pub fn status(&self) -> SessionStatus { - // Terminal states take precedence over expiration: a session that has reached - // a `Closed` outcome is done regardless of whether its expiration has elapsed. match self.events.last() { Some(SessionEvent::Closed(outcome)) => match outcome { SessionOutcome::Success(_) => SessionStatus::Completed, SessionOutcome::Aborted => SessionStatus::Failed, }, + #[cfg(feature = "std")] _ if self.pj_param().expiration().elapsed() => SessionStatus::Expired, _ => SessionStatus::Active, } @@ -155,6 +170,7 @@ pub enum SessionStatus { Completed, } +#[cfg(feature = "v2-ohttp")] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SessionEvent { /// Sender was created with session data From b07231ab6f70a98fc8d3c351dd8cae3a5a148a6c Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Sat, 6 Jun 2026 22:22:36 -0300 Subject: [PATCH 3/9] feat: declare nostd feature in payjoin/Cargo.toml --- fuzz/Cargo.toml | 1 + payjoin/Cargo.toml | 40 +++++++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 14e7962ad..756a48242 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -15,6 +15,7 @@ home = "=0.5.11" libfuzzer-sys = { version = "0.4.10" } payjoin = { path = "../payjoin", default-features = false, features = [ "_core", + "std", "v1", "v2", ] } diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index d75809b5a..f184be80a 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -19,11 +19,14 @@ exclude = ["tests"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["v2"] -#[doc = "Core features for payjoin state machines"] -_core = [ +default = ["std", "v2", "v2-ohttp"] + +alloc = [] + +std = [ + "alloc", "bitcoin/rand-std", - "dep:http", + "bitcoin/base64", "serde_json", "dep:percent-encoding-rfc3986", "bitcoin_uri", @@ -31,17 +34,23 @@ _core = [ "serde", "bitcoin/serde", ] -directory = [] -v1 = ["_core"] -v2 = ["_core", "hpke", "hkdf", "bhttp", "ohttp", "directory"] +#[doc = "Core features for payjoin state machines"] +_core = ["alloc", "serde", "bitcoin/serde"] + +directory = ["alloc", "_core"] +v1 = ["_core", "std"] + +v2 = ["_core", "directory", "hkdf"] + +v2-ohttp = ["v2", "std", "dep:hpke", "dep:bhttp", "dep:ohttp", "dep:http"] #[doc = "Functions to fetch OHTTP keys via CONNECT proxy using reqwest. Enables `v2` since only `v2` uses OHTTP."] -io = ["v2", "reqwest/rustls-tls"] -_manual-tls = ["rustls"] +io = ["v2-ohttp", "reqwest/rustls-tls", "dep:reqwest"] +_manual-tls = ["reqwest/rustls-tls", "rustls"] [dependencies] bhttp = { version = "0.6.1", optional = true } -bitcoin = { version = "0.32.9", features = ["base64"] } -bitcoin-units = "0.1.3" +bitcoin = { version = "0.32.9", default-features = false } +bitcoin-units = { version = "0.1.3", default-features = false } bitcoin_uri = { version = "0.1.0", optional = true } hkdf = { version = "0.12.3", optional = true } hpke = { package = "bitcoin-hpke", version = "0.13.0", optional = true } @@ -54,7 +63,9 @@ rustls = { version = "0.23.38", optional = true, default-features = false, featu ] } serde = { version = "1.0.228", default-features = false, optional = true } serde_json = { version = "1.0.149", optional = true } -tracing = "0.1.41" +tracing = { version = "0.1.41", default-features = false, features = [ + "attributes", +] } [target.'cfg(target_arch = "wasm32")'.dependencies] web-time = "1.1.0" @@ -67,7 +78,10 @@ ignored = ["bitcoin-units", "hkdf"] once_cell = "1.21.3" payjoin-test-utils = { path = "../payjoin-test-utils", features = ["v2"] } tokio = { version = "1.52.3", features = ["full"] } -tracing = "0.1.41" +tracing = { version = "0.1.41", default-features = false, features = [ + "attributes", +] } + [package.metadata.docs.rs] all-features = true From cfd8ea7227929c069d07e1bdeb72b8bd18f62f0a Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Tue, 9 Jun 2026 17:20:40 -0300 Subject: [PATCH 4/9] chore: update CI, lock files and flake for no_std targets --- .github/workflows/rust.yml | 18 ++++++++++++++++++ flake.nix | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e6fbe5f8f..61652714a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -145,3 +145,21 @@ jobs: uses: Swatinem/rust-cache@v2 - name: "Build fuzz targets" run: cd fuzz && cargo build + + Embedded: + name: Embedded build + runs-on: ubuntu-latest + steps: + - name: "Checkout repo" + uses: actions/checkout@v4 + - name: "Install nightly toolchain" + uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + targets: thumbv7em-none-eabihf + - name: Install ARM cross compiler + run: sudo apt-get update && sudo apt-get install -y gcc-arm-none-eabi + - name: "Build embedded target" + env: + CC_thumbv7em_none_eabihf: arm-none-eabi-gcc + run: cargo build -p payjoin --no-default-features --features "alloc,v2" --target thumbv7em-none-eabihf -Zbuild-std=core,alloc diff --git a/flake.nix b/flake.nix index 8a33ea980..c253a1527 100644 --- a/flake.nix +++ b/flake.nix @@ -345,6 +345,24 @@ AR_wasm32_unknown_unknown = "${pkgs.llvmPackages.bintools-unwrapped}/bin/llvm-ar"; }; + embeddedRustToolchain = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml).override { + extensions = [ + "rust-src" + "rustfmt" + "llvm-tools-preview" + ]; + targets = [ "thumbv7em-none-eabihf" ]; + }; + + embeddedDevShell = pkgs.mkShell { + name = "embedded-dev"; + packages = with pkgs; [ + embeddedRustToolchain + gcc-arm-embedded + ]; + CC_thumbv7em_none_eabihf = "arm-none-eabi-gcc"; + }; + dartDevShell = pkgs.mkShell { name = "dart-dev"; packages = @@ -484,6 +502,7 @@ javascript = javascriptDevShell; csharp = csharpDevShell; dart = dartDevShell; + embedded = embeddedDevShell; }; formatter = treefmtEval.config.build.wrapper; checks = From ccfb6763835a519d2e5d842fe0e02308a887092b Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 10 Jun 2026 18:46:08 -0300 Subject: [PATCH 5/9] chore: use nix develop .#embedded for CI embedded build --- .github/workflows/rust.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 61652714a..37211ec73 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -152,14 +152,11 @@ jobs: steps: - name: "Checkout repo" uses: actions/checkout@v4 - - name: "Install nightly toolchain" - uses: dtolnay/rust-toolchain@nightly - with: - components: rust-src - targets: thumbv7em-none-eabihf - - name: Install ARM cross compiler - run: sudo apt-get update && sudo apt-get install -y gcc-arm-none-eabi + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + - name: "Install nix" + uses: DeterminateSystems/determinate-nix-action@main + - name: "Use nix cache" + uses: DeterminateSystems/magic-nix-cache-action@main - name: "Build embedded target" - env: - CC_thumbv7em_none_eabihf: arm-none-eabi-gcc - run: cargo build -p payjoin --no-default-features --features "alloc,v2" --target thumbv7em-none-eabihf -Zbuild-std=core,alloc + run: nix develop .#embedded -c cargo build -p payjoin --no-default-features --features "alloc,v2" --target thumbv7em-none-eabihf -Zbuild-std=core,alloc From af94fb41f6f3d8b0e514e6b19bd082475b891528 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Sun, 12 Jul 2026 17:04:43 -0300 Subject: [PATCH 6/9] Add no_std support for payjoin v1 The v1 feature required std unconditionally, even though the protocol itself has no networking or OS dependency: it is plain byte-in/byte-out request/response handling, making it the natural fit for the no_std embedded receiver/sender work in this branch. Port the v1 send and receive paths to alloc-only: - Change the v1 feature to depend on alloc instead of std. - Gate the handful of genuinely std-only APIs (PjUri construction, SenderBuilder::new) behind std, leaving SenderBuilder::from_parts and PjParam::parse as the no_std entry points. - Replace bitcoin's Psbt FromStr/Display, which pulls in std through bitcoin's base64 feature, with direct use of a no_std-configured base64 crate on top of PSBT's existing binary (de)serialization. Shared with the v2 receive path, which used the same std-only entry point. - Fix several imports and cfg gates that were tied to std without a real std dependency (FeeRate, Version, FromStr, format!, and the query-string parsing in Params::from_query_str). - Simplify from_query_str's query parsing to avoid a percent-decoding dependency that is itself std-only; BIP78 query parameters don't need percent-decoding in practice. - Add a no_std fallback for ResponseError parsing, since the well-known JSON error format requires serde_json (std-only); it reports a generic parse failure instead of the decoded reason. --- Cargo-minimal.lock | 15 +- Cargo-recent.lock | 1 + payjoin-cli/Cargo.toml | 2 +- payjoin/Cargo.toml | 9 +- payjoin/src/core/mod.rs | 4 +- payjoin/src/core/psbt/mod.rs | 23 +++ payjoin/src/core/receive/error.rs | 4 +- payjoin/src/core/receive/mod.rs | 18 +-- .../src/core/receive/optional_parameters.rs | 9 +- payjoin/src/core/receive/v1/error.rs | 3 +- payjoin/src/core/receive/v1/mod.rs | 12 +- payjoin/src/core/request.rs | 2 + payjoin/src/core/send/error.rs | 6 +- payjoin/src/core/send/v1.rs | 33 ++-- payjoin/src/core/uri/mod.rs | 141 +++++++++--------- payjoin/src/core/uri/v1.rs | 4 +- 16 files changed, 153 insertions(+), 133 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 477744931..af710f95f 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -452,9 +452,9 @@ dependencies = [ [[package]] name = "base64" -version = "0.21.3" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" @@ -542,7 +542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cf93e61f2dbc3e3c41234ca26a65e2c0b0975c52e0f069ab9893ebbede584d3" dependencies = [ "base58ck", - "base64 0.21.3", + "base64 0.21.7", "bech32", "bitcoin-internals 0.3.0", "bitcoin-io", @@ -2569,6 +2569,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" name = "payjoin" version = "1.0.0-rc.8" dependencies = [ + "base64 0.21.7", "bhttp", "bitcoin", "bitcoin-hpke", @@ -2736,7 +2737,7 @@ version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3163d2912b7c3b52d651a055f2c7eec9ba5cd22d26ef75b8dd3a59980b185923" dependencies = [ - "base64 0.21.3", + "base64 0.21.7", "serde", ] @@ -3258,7 +3259,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ - "base64 0.21.3", + "base64 0.21.7", "bitflags 2.5.0", "serde", "serde_derive", @@ -3355,7 +3356,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35e4980fa29e4c4b212ffb3db068a564cbf560e51d3944b7c88bd8bf5bec64f4" dependencies = [ - "base64 0.21.3", + "base64 0.21.7", "rustls-pki-types", ] @@ -3589,7 +3590,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513" dependencies = [ - "base64 0.21.3", + "base64 0.21.7", "chrono", "hex", "indexmap 1.8.0", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index c82185c52..49c4afacb 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -2700,6 +2700,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" name = "payjoin" version = "1.0.0-rc.8" dependencies = [ + "base64 0.21.7", "bhttp", "bitcoin", "bitcoin-hpke", diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index 818feeaa1..144c3ee47 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -21,7 +21,7 @@ path = "src/main.rs" default = ["v2"] native-certs = ["reqwest/rustls-tls-native-roots"] _manual-tls = ["reqwest/rustls-tls", "payjoin/_manual-tls", "tokio-rustls"] -v1 = ["payjoin/v1", "hyper", "hyper-util", "http-body-util"] +v1 = ["payjoin/v1", "payjoin/std", "hyper", "hyper-util", "http-body-util"] v2 = ["payjoin/v2", "payjoin/io"] [dependencies] diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index f184be80a..9b7c02a57 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -38,9 +38,9 @@ std = [ _core = ["alloc", "serde", "bitcoin/serde"] directory = ["alloc", "_core"] -v1 = ["_core", "std"] +v1 = ["_core", "alloc", "dep:base64"] -v2 = ["_core", "directory", "hkdf"] +v2 = ["_core", "directory", "hkdf", "dep:base64"] v2-ohttp = ["v2", "std", "dep:hpke", "dep:bhttp", "dep:ohttp", "dep:http"] #[doc = "Functions to fetch OHTTP keys via CONNECT proxy using reqwest. Enables `v2` since only `v2` uses OHTTP."] @@ -48,8 +48,11 @@ io = ["v2-ohttp", "reqwest/rustls-tls", "dep:reqwest"] _manual-tls = ["reqwest/rustls-tls", "rustls"] [dependencies] +base64 = { version = "0.21.7", default-features = false, features = [ + "alloc", +], optional = true } bhttp = { version = "0.6.1", optional = true } -bitcoin = { version = "0.32.9", default-features = false } +bitcoin = { version = "0.32.9", default-features = false, features = ["rand"] } bitcoin-units = { version = "0.1.3", default-features = false } bitcoin_uri = { version = "0.1.0", optional = true } hkdf = { version = "0.12.3", optional = true } diff --git a/payjoin/src/core/mod.rs b/payjoin/src/core/mod.rs index 5b2f06d8e..12dd90172 100644 --- a/payjoin/src/core/mod.rs +++ b/payjoin/src/core/mod.rs @@ -29,9 +29,9 @@ pub mod time; pub mod uri; #[cfg(feature = "std")] pub use uri::PjUri; -pub use uri::{PjParam, PjParseError}; #[cfg(feature = "std")] -pub use uri::{Uri, UriExt}; +pub use uri::Uri; +pub use uri::{PjParam, PjParseError}; pub(crate) mod error_codes; pub(crate) mod output_substitution; diff --git a/payjoin/src/core/psbt/mod.rs b/payjoin/src/core/psbt/mod.rs index ab59c2e49..90d718fce 100644 --- a/payjoin/src/core/psbt/mod.rs +++ b/payjoin/src/core/psbt/mod.rs @@ -4,6 +4,8 @@ use alloc::boxed::Box; #[cfg(not(feature = "std"))] use alloc::collections::BTreeMap; +#[cfg(any(feature = "v1", feature = "v2"))] +use alloc::string::String; #[cfg(not(feature = "std"))] use alloc::vec::Vec; use core::fmt; @@ -442,6 +444,27 @@ impl From for InputWeightError { fn from(value: AddressTypeError) -> Self { Self::AddressType(value) } } +/// Base64 (de)serialization for the BIP78 v1 wire format. +/// +/// `bitcoin`'s own `base64` feature pulls in the `base64` crate with its +/// default (`std`-only) features enabled, which breaks `no_std` builds. +/// These helpers use a directly-depended, `alloc`-only build of `base64` +/// instead, on top of PSBT's always-available binary (de)serialization. +#[cfg(any(feature = "v1", feature = "v2"))] +pub(crate) fn psbt_to_base64(psbt: &Psbt) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(psbt.serialize()) +} + +#[cfg(any(feature = "v1", feature = "v2"))] +pub(crate) fn psbt_from_base64(s: &str) -> Result { + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|_| bitcoin::psbt::Error::InvalidMagic)?; + Psbt::deserialize(&bytes) +} + #[cfg(test)] mod test { use bitcoin::{Psbt, ScriptBuf, Transaction, TxOut}; diff --git a/payjoin/src/core/receive/error.rs b/payjoin/src/core/receive/error.rs index 91462460f..a1011c653 100644 --- a/payjoin/src/core/receive/error.rs +++ b/payjoin/src/core/receive/error.rs @@ -202,8 +202,7 @@ pub(crate) enum InternalPayloadError { /// The payload is not valid utf-8 Utf8(core::str::Utf8Error), /// The payload is not a valid PSBT - #[cfg(feature = "std")] - ParsePsbt(bitcoin::psbt::PsbtParseError), + ParsePsbt(bitcoin::psbt::Error), /// Invalid sender parameters SenderParams(super::optional_parameters::Error), /// The raw PSBT fails bip78-specific validation. @@ -275,7 +274,6 @@ impl fmt::Display for InternalPayloadError { match &self { Utf8(e) => write!(f, "{e}"), - #[cfg(feature = "std")] ParsePsbt(e) => write!(f, "{e}"), SenderParams(e) => write!(f, "{e}"), InconsistentPsbt(e) => write!(f, "{e}"), diff --git a/payjoin/src/core/receive/mod.rs b/payjoin/src/core/receive/mod.rs index 53476404c..b96600f84 100644 --- a/payjoin/src/core/receive/mod.rs +++ b/payjoin/src/core/receive/mod.rs @@ -13,16 +13,12 @@ use alloc::collections::BTreeMap; use alloc::vec::Vec; #[cfg(not(feature = "std"))] use alloc::{format, vec}; -#[cfg(feature = "std")] -use core::str::FromStr; pub mod common; use bitcoin::transaction::InputWeightPrediction; -#[cfg(feature = "std")] -use bitcoin::FeeRate; use bitcoin::{ - psbt, AddressType, OutPoint, Psbt, Script, ScriptBuf, Transaction, TxIn, TxOut, Weight, + psbt, AddressType, FeeRate, OutPoint, Psbt, Script, ScriptBuf, Transaction, TxIn, TxOut, Weight, }; pub(crate) use error::InternalPayloadError; #[cfg(feature = "std")] @@ -40,9 +36,8 @@ use crate::psbt::{ NON_WITNESS_INPUT_WEIGHT, }; use crate::ImplementationError; -#[cfg(feature = "std")] +#[allow(unused_imports)] use crate::Version; - /// Input weight for a P2TR key-spend with default sighash (64-byte signature) and no annex. const DEFAULT_SIGHASH_KEY_SPEND_INPUT_WEIGHT: Weight = Weight::from_wu( InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH.weight().to_wu() @@ -250,7 +245,8 @@ pub(crate) fn parse_payload( query: &str, supported_versions: &'static [Version], ) -> Result<(Psbt, Params), PayloadError> { - let unchecked_psbt = Psbt::from_str(base64).map_err(InternalPayloadError::ParsePsbt)?; + let unchecked_psbt = + crate::psbt::psbt_from_base64(base64).map_err(InternalPayloadError::ParsePsbt)?; let psbt = unchecked_psbt.validate().map_err(InternalPayloadError::InconsistentPsbt)?; psbt.validate_input_utxos().map_err(InternalPayloadError::InvalidInputUtxo)?; @@ -395,15 +391,11 @@ pub struct OriginalPayload { impl OriginalPayload { // Calculates the fee rate of the original proposal PSBT. - #[cfg(feature = "std")] fn psbt_fee_rate(&self) -> Result { - let original_psbt_fee = self.psbt.fee().map_err(|e| { - InternalPayloadError::ParsePsbt(bitcoin::psbt::PsbtParseError::PsbtEncoding(e)) - })?; + let original_psbt_fee = self.psbt.fee().map_err(InternalPayloadError::ParsePsbt)?; Ok(original_psbt_fee / self.psbt.clone().extract_tx_unchecked_fee_rate().weight()) } - #[cfg(feature = "std")] pub fn check_broadcast_suitability( &self, min_fee_rate: Option, diff --git a/payjoin/src/core/receive/optional_parameters.rs b/payjoin/src/core/receive/optional_parameters.rs index bd1cc5897..d9fbbbc27 100644 --- a/payjoin/src/core/receive/optional_parameters.rs +++ b/payjoin/src/core/receive/optional_parameters.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "std")] -use alloc::format; use alloc::string::String; use core::borrow::Borrow; #[cfg(not(feature = "std"))] @@ -129,14 +127,13 @@ impl Params { Ok(params) } - #[cfg(feature = "std")] pub fn from_query_str( query: &str, supported_versions: &'static [Version], ) -> Result { - let url = crate::Url::parse(&format!("http://localhost/?{query}")) - .map_err(|_| Error::MalformedQuery)?; - Self::from_query_pairs(url.query_pairs().into_iter(), supported_versions) + let pairs = + query.split('&').filter(|s| !s.is_empty()).filter_map(|pair| pair.split_once('=')); + Self::from_query_pairs(pairs, supported_versions) } } diff --git a/payjoin/src/core/receive/v1/error.rs b/payjoin/src/core/receive/v1/error.rs index eec90e08a..78c16373a 100644 --- a/payjoin/src/core/receive/v1/error.rs +++ b/payjoin/src/core/receive/v1/error.rs @@ -1,3 +1,4 @@ +use alloc::string::String; #[cfg(not(feature = "std"))] use core::error; use core::fmt; @@ -24,7 +25,7 @@ pub(crate) enum InternalRequestError { /// The Content-Type header has an invalid value InvalidContentType(String), /// The Content-Length header could not be parsed as a number - InvalidContentLength(std::num::ParseIntError), + InvalidContentLength(core::num::ParseIntError), /// The Content-Length value does not match the actual body length ContentLengthMismatch { expected: usize, actual: usize }, } diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index 22c44c85f..cae3de6ce 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -37,17 +37,21 @@ pub(crate) use error::InternalRequestError; pub use error::RequestError; use super::*; +use crate::alloc::borrow::ToOwned; pub use crate::receive::common::{WantsFeeRange, WantsInputs, WantsOutputs}; +#[cfg(feature = "std")] use crate::uri::PjParam; -use crate::{IntoUrl, OutputSubstitution, PjParseError, Version}; - +use crate::Version; +#[cfg(feature = "std")] +use crate::{IntoUrl, OutputSubstitution, PjParseError}; const SUPPORTED_VERSIONS: &[Version] = &[Version::One]; pub trait Headers { fn get_header(&self, key: &str) -> Option<&str>; } -pub fn build_v1_pj_uri( +#[cfg(feature = "std")] +pub fn build_v1_pj_uri<'a>( address: &bitcoin::Address, endpoint: impl IntoUrl, output_substitution: OutputSubstitution, @@ -61,7 +65,7 @@ impl UncheckedOriginalPayload { pub fn from_request(body: &[u8], query: &str, headers: impl Headers) -> Result { let validated_body = validate_body(headers, body).map_err(ProtocolError::V1)?; - let base64 = std::str::from_utf8(validated_body).map_err(InternalPayloadError::Utf8)?; + let base64 = core::str::from_utf8(validated_body).map_err(InternalPayloadError::Utf8)?; let (psbt, params) = crate::receive::parse_payload(base64, query, SUPPORTED_VERSIONS) .map_err(ProtocolError::OriginalPayload)?; diff --git a/payjoin/src/core/request.rs b/payjoin/src/core/request.rs index 7dc256abd..2a4bf6c23 100644 --- a/payjoin/src/core/request.rs +++ b/payjoin/src/core/request.rs @@ -1,4 +1,6 @@ use alloc::string::String; +#[cfg(feature = "v1")] +use alloc::string::ToString; use alloc::vec::Vec; #[cfg(any(feature = "v1", feature = "v2-ohttp"))] diff --git a/payjoin/src/core/send/error.rs b/payjoin/src/core/send/error.rs index 1070f5901..63a44be08 100644 --- a/payjoin/src/core/send/error.rs +++ b/payjoin/src/core/send/error.rs @@ -73,7 +73,9 @@ impl fmt::Display for BuildSenderError { } } -#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +#[cfg(all(not(feature = "std"), any(feature = "v1", feature = "v2-ohttp")))] +impl core::error::Error for BuildSenderError {} +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] impl std::error::Error for BuildSenderError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { use InternalBuildSenderError::*; @@ -402,7 +404,7 @@ impl ResponseError { } } - #[cfg(any(feature = "v1", test))] + #[cfg(any(all(feature = "v1", feature = "std"), test))] pub(crate) fn parse_from_str(s: &str) -> Result { let json: serde_json::Value = serde_json::from_str(s)?; Ok(Self::from_json(json)) diff --git a/payjoin/src/core/send/v1.rs b/payjoin/src/core/send/v1.rs index 6941a6538..313f14fb1 100644 --- a/payjoin/src/core/send/v1.rs +++ b/payjoin/src/core/send/v1.rs @@ -21,7 +21,7 @@ //! [`bitmask-core`](https://github.com/diba-io/bitmask-core) BDK integration. Bring your own //! wallet and http client. -use core::str::FromStr; +use alloc::string::{String, ToString}; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate}; @@ -32,7 +32,9 @@ use super::*; use crate::core::Url; pub use crate::output_substitution::OutputSubstitution; use crate::uri::v1::PjParam; -use crate::{PjUri, Request, Version, MAX_CONTENT_LENGTH}; +#[cfg(feature = "std")] +use crate::PjUri; +use crate::{Request, Version, MAX_CONTENT_LENGTH}; /// A builder to construct the properties of a `Sender`. #[derive(Clone)] @@ -47,6 +49,7 @@ impl SenderBuilder { /// /// Call [`SenderBuilder::build_recommended()`] or other `build` methods /// to create a [`Sender`] + #[cfg(feature = "std")] pub fn new(psbt: Psbt, uri: PjUri) -> Self { Self { endpoint: uri.extras().pj_param().endpoint_url(), @@ -175,7 +178,7 @@ impl Sender { ); let mut sanitized_psbt = self.psbt_ctx.original_psbt.clone(); clear_unneeded_fields(&mut sanitized_psbt); - let body = sanitized_psbt.to_string().as_bytes().to_vec(); + let body = crate::psbt::psbt_to_base64(&sanitized_psbt).into_bytes(); ( Request::new_v1(&url, &body), V1Context { @@ -214,29 +217,23 @@ impl V1Context { } let res_str = core::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?; - let proposal = Psbt::from_str(res_str).map_err(|_| { - ResponseError::parse_from_str(res_str) - .unwrap_or_else(|_| InternalValidationError::Parse.into()) - })?; + let proposal = + crate::psbt::psbt_from_base64(res_str).map_err(|_| parse_error_response(res_str))?; self.psbt_context.process_proposal(proposal).map_err(Into::into) } } -impl ResponseError { - /// Parse a response from the receiver. - /// - /// response must be valid JSON string. - #[cfg(not(feature = "std"))] - pub(crate) fn parse(response: &str) -> Self { - match serde_json::from_str(response) { - Ok(json) => Self::from_json(json), - Err(_) => InternalValidationError::Parse.into(), - } - } +#[cfg(feature = "std")] +fn parse_error_response(res_str: &str) -> ResponseError { + ResponseError::parse_from_str(res_str).unwrap_or_else(|_| InternalValidationError::Parse.into()) } +#[cfg(not(feature = "std"))] +fn parse_error_response(_res_str: &str) -> ResponseError { InternalValidationError::Parse.into() } + #[cfg(test)] mod test { + use core::str::FromStr; use std::collections::BTreeMap; use bitcoin::bip32::{self, DerivationPath}; diff --git a/payjoin/src/core/uri/mod.rs b/payjoin/src/core/uri/mod.rs index 5ce722066..966f60009 100644 --- a/payjoin/src/core/uri/mod.rs +++ b/payjoin/src/core/uri/mod.rs @@ -1,24 +1,25 @@ //! Payjoin URI parsing and validation -#[cfg(feature = "std")] -mod imports { - pub use alloc::borrow::Cow; - pub use alloc::boxed::Box; - pub use alloc::fmt; - pub use alloc::vec::Vec; - pub use std::vec; - pub use core::str::FromStr; - pub use bitcoin::address::{NetworkChecked, NetworkUnchecked, NetworkValidation}; - pub use bitcoin::{Address, Amount}; -} - +use alloc::borrow::Cow; +use alloc::boxed::Box; +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +use alloc::fmt; +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +use alloc::string::{String, ToString}; +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] +use alloc::vec; +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] +use alloc::vec::Vec; +use core::str::FromStr; + +use bitcoin::address::{NetworkChecked, NetworkUnchecked, NetworkValidation}; +use bitcoin::{Address, Amount}; pub use error::{PjParseError, UriParseError}; -#[cfg(feature = "std")] -use imports::*; #[cfg(feature = "v2-ohttp")] pub(crate) use crate::directory::ShortId; use crate::output_substitution::OutputSubstitution; #[cfg(feature = "std")] +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] use crate::uri::error::InternalPjParseError; mod error; @@ -129,78 +130,77 @@ pub struct PayjoinExtras { impl PayjoinExtras { pub fn pj_param(&self) -> &PjParam { &self.pj_param } + #[cfg(any(feature = "v1", feature = "v2-ohttp"))] pub fn endpoint(&self) -> String { self.pj_param.endpoint() } + pub fn output_substitution(&self) -> OutputSubstitution { self.output_substitution } } - /// A BIP21 URI that may or may not request payjoin. - /// - /// This newtype wraps [`bitcoin_uri::Uri`] so that a breaking change in that - /// crate does not force a breaking change in this crate's public API. Parse one - /// with [`Uri::try_from`] or [`str::parse`], validate the address network with - /// [`assume_checked`](Self::assume_checked) or - /// [`require_network`](Self::require_network), then check for payjoin support - /// with [`check_pj_supported`](Self::check_pj_supported). - /// - /// The URI is always owned, so it carries no lifetime parameter. - #[derive(Clone, Debug)] - pub struct Uri( - bitcoin_uri::Uri<'static, NetVal, MaybePayjoinExtrasAdapter>, - ); - - impl Uri { - /// The address the URI pays to. - pub fn address(&self) -> &Address { &self.0.address } - - /// The amount the URI requests, if any. - pub fn amount(&self) -> Option { self.0.amount } - - /// The label describing the URI, if present and valid UTF-8. - pub fn label(&self) -> Option { - self.0.label.clone().and_then(|label| String::try_from(label).ok()) - } +/// A BIP21 URI that may or may not request payjoin. +/// +/// This newtype wraps [`bitcoin_uri::Uri`] so that a breaking change in that +/// crate does not force a breaking change in this crate's public API. Parse one +/// with [`Uri::try_from`] or [`str::parse`], validate the address network with +/// [`assume_checked`](Self::assume_checked) or +/// [`require_network`](Self::require_network), then check for payjoin support +/// with [`check_pj_supported`](Self::check_pj_supported). +/// +/// The URI is always owned, so it carries no lifetime parameter. +#[derive(Clone, Debug)] +pub struct Uri( + bitcoin_uri::Uri<'static, NetVal, MaybePayjoinExtrasAdapter>, +); - /// The message describing the URI, if present and valid UTF-8. - pub fn message(&self) -> Option { - self.0.message.clone().and_then(|message| String::try_from(message).ok()) - } +impl Uri { + /// The address the URI pays to. + pub fn address(&self) -> &Address { &self.0.address } + + /// The amount the URI requests, if any. + pub fn amount(&self) -> Option { self.0.amount } - /// The payjoin parameters carried by the URI. - pub fn extras(&self) -> &MaybePayjoinExtras { &self.0.extras.0 } + /// The label describing the URI, if present and valid UTF-8. + pub fn label(&self) -> Option { + self.0.label.clone().and_then(|label| String::try_from(label).ok()) } - impl Uri { - /// Marks the URI's address as validated without checking the network. - pub fn assume_checked(self) -> Uri { Uri(self.0.assume_checked()) } + /// The message describing the URI, if present and valid UTF-8. + pub fn message(&self) -> Option { + self.0.message.clone().and_then(|message| String::try_from(message).ok()) + } - /// Validates that the URI's address is valid for the given network. - pub fn require_network( - self, - network: bitcoin::Network, - ) -> Result, UriParseError> { - self.0.require_network(network).map(Uri).map_err(UriParseError::from_bip21_error) - } + /// The payjoin parameters carried by the URI. + pub fn extras(&self) -> &MaybePayjoinExtras { &self.0.extras.0 } +} + +impl Uri { + /// Marks the URI's address as validated without checking the network. + pub fn assume_checked(self) -> Uri { Uri(self.0.assume_checked()) } + + /// Validates that the URI's address is valid for the given network. + pub fn require_network( + self, + network: bitcoin::Network, + ) -> Result, UriParseError> { + self.0.require_network(network).map(Uri).map_err(UriParseError::from_bip21_error) } +} - impl Uri { - /// Converts this URI into a [`PjUri`] if it supports payjoin. - /// - /// If payjoin is unsupported the URI is handed back unchanged in the error - /// variant. It is boxed to reduce the size of the `Result` (see - /// ). - #[cfg(feature = "std")] - pub fn check_pj_supported(self) -> Result> { - match self.0.extras.0 { - MaybePayjoinExtras::Supported(payjoin) => { - let mut uri = +impl Uri { + /// Converts this URI into a [`PjUri`] if it supports payjoin. + /// + /// If payjoin is unsupported the URI is handed back unchanged in the error + /// variant. It is boxed to reduce the size of the `Result` (see + /// ). + #[cfg(feature = "std")] + pub fn check_pj_supported(self) -> Result> { + match self.0.extras.0 { MaybePayjoinExtras::Supported(payjoin) => { let mut uri = bitcoin_uri::Uri::with_extras(self.0.address, PayjoinExtrasAdapter(payjoin)); uri.amount = self.0.amount; uri.label = self.0.label; uri.message = self.0.message; - Ok(PjUri(uri)) } MaybePayjoinExtras::Unsupported => { @@ -211,7 +211,6 @@ impl PayjoinExtras { uri.amount = self.0.amount; uri.label = self.0.label; uri.message = self.0.message; - Err(Box::new(Uri(uri))) } } @@ -345,7 +344,7 @@ impl bitcoin_uri::SerializeParams for &PayjoinExtrasAdapter { fn serialize_params(self) -> Self::Iterator { serialize_payjoin_params(&self.0).into_iter() } } -#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] impl bitcoin_uri::de::DeserializationState<'_> for DeserializationState { type Value = MaybePayjoinExtrasAdapter; @@ -410,7 +409,7 @@ pub(crate) fn pj_uri(uri: &str) -> PjUri { mod tests { use std::convert::TryFrom; - #[cfg(feature = "v1")] + #[cfg(all(feature = "std", feature = "v1"))] use bitcoin_uri::SerializeParams; use super::*; diff --git a/payjoin/src/core/uri/v1.rs b/payjoin/src/core/uri/v1.rs index 8ff223886..1bcf3c58c 100644 --- a/payjoin/src/core/uri/v1.rs +++ b/payjoin/src/core/uri/v1.rs @@ -24,8 +24,8 @@ impl PjParam { pub(crate) fn endpoint(&self) -> Url { self.0.clone() } } -impl std::fmt::Display for PjParam { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl core::fmt::Display for PjParam { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { // Use the same display logic as the encapsulated child Url self.0.fmt(f) } From fb7d9cbd9d43c292301ed6d600c2379050ed91a4 Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Wed, 15 Jul 2026 13:36:26 -0300 Subject: [PATCH 7/9] Add e2e round trip tests for payjoin v1 and v2 Add payjoin/tests/e2e.rs with two independent round trip tests, split into cfg-gated submodules since v1 and v2 have different feature requirements: - v1: runs entirely in memory under the no_std-compatible alloc,v1 feature set. Drives the full sender and receiver typestate chain with no OHTTP or directory involved, since v1 is plain request/response bytes. This is the test that actually exercises the no_std surface end to end, not just at compile time. - v2: cannot run under that restricted feature set, since the sender side of v2 still requires v2-ohttp/std. Instead this proves that the receiver side, driven by a real OHTTP-encapsulated request from a real sender, walks the same typestate chain already verified to compile for thumbv7em-none-eabihf. Uses a small in-memory stand-in for the directory and OHTTP relay (decapsulate, route GET/POST against a single-message mailbox, re-encapsulate) instead of the real local servers tests/integration.rs already spins up, so it stays fast and dependency-free. Both tests have the receiver contribute a real input from its own UTXO before finalizing, rather than only fee-bumping the original PSBT, so they exercise an actual payjoin (combined inputs from both parties) and not just protocol plumbing. Wire both into contrib/test.sh: the v2 module runs as part of the --all-features e2e run, and v1 gets its own explicit --no-default-features --features alloc,v1 invocation, since that's the only place the no_std guarantee is actually exercised at runtime rather than just checked at compile time. --- payjoin/contrib/test.sh | 2 + payjoin/tests/e2e.rs | 413 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 415 insertions(+) create mode 100644 payjoin/tests/e2e.rs diff --git a/payjoin/contrib/test.sh b/payjoin/contrib/test.sh index 190dcae9a..fbebe5c04 100755 --- a/payjoin/contrib/test.sh +++ b/payjoin/contrib/test.sh @@ -5,6 +5,8 @@ features=("v1" "v2") cargo test --locked --package payjoin --verbose --all-features --lib cargo test --locked --package payjoin --verbose --all-features --test integration +cargo test --locked --package payjoin --verbose --all-features --test e2e +cargo test --locked --package payjoin --verbose --no-default-features --features alloc,v1 --test e2e for feature in "${features[@]}"; do cargo test --locked --package payjoin --verbose --no-default-features --features "$feature" --lib --no-run diff --git a/payjoin/tests/e2e.rs b/payjoin/tests/e2e.rs new file mode 100644 index 000000000..1b3e10f71 --- /dev/null +++ b/payjoin/tests/e2e.rs @@ -0,0 +1,413 @@ +//! Payjoin end-to-end round trip tests, in memory, no real network. +//! +//! Split into two submodules because v1 and v2 have very different +//! feature requirements: +//! +//! - `v1` runs under the `no_std`-compatible `alloc,v1` feature set (that's +//! the whole point of it) and needs no OHTTP/directory infrastructure, +//! since the protocol itself is transport-agnostic plain bytes. +//! Run in isolation: +//! cargo test -p payjoin --no-default-features --features alloc,v1 --test e2e +//! +//! - `v2` cannot run under that restricted feature set: the sender side of +//! v2 genuinely requires `v2-ohttp`/`std` today (see the PR discussion +//! for why). What it proves instead is that the *receiver* side, driven +//! by a real, correctly OHTTP-encapsulated request from a real sender, +//! walks through exactly the same typestate chain that is separately +//! verified to compile under `alloc,v2` for `thumbv7em-none-eabihf`. +//! That's the code path that will run on an embedded receiver device. +//! Needs default features: +//! cargo test -p payjoin --test e2e +//! +//! Neither module proves `no_std` purity on its own (dev-dependencies pull +//! in std regardless, and the v2 module needs std anyway). That guarantee +//! comes from the separate CI step that cross-compiles the library for +//! thumbv7em-none-eabihf. `tests/integration.rs` separately covers full v2 +//! round trips against real local directory + OHTTP relay servers; the +//! `v2` module here intentionally avoids that infrastructure (no `tokio`, +//! no real sockets) by hand-rolling a minimal in-memory stand-in for the +//! directory + relay, closer in shape to what an embedded harness will +//! look like, where the host does the transport and the device only ever +//! sees decrypted application bytes. + +#[cfg(feature = "v1")] +mod v1 { + use std::str::FromStr; + + use base64::Engine; + use bitcoin::{Address, Amount, FeeRate, Network}; + use payjoin::receive::v1::{Headers, UncheckedOriginalPayload}; + use payjoin::send::v1::SenderBuilder; + use payjoin::PjParam; + use payjoin_test_utils::PARSED_ORIGINAL_PSBT; + + /// Minimal [`Headers`] implementation for feeding a raw request body + /// into [`UncheckedOriginalPayload::from_request`], mirroring what a + /// receiver's own HTTP-adjacent transport (or in our case, serial + /// framing) would supply. + struct FixedHeaders { + content_length: String, + } + + impl FixedHeaders { + fn for_body(body: &[u8]) -> Self { Self { content_length: body.len().to_string() } } + } + + impl Headers for FixedHeaders { + fn get_header(&self, key: &str) -> Option<&str> { + match key { + "content-length" => Some(&self.content_length), + "content-type" => Some("text/plain"), + _ => None, + } + } + } + + /// Splits a full request URL into its query string, the way a device + /// would after receiving `Request.url` from the host relay. + fn query_of(url: &str) -> &str { url.split('?').nth(1).unwrap_or("") } + + #[test] + fn v1_round_trip_sender_and_receiver() -> Result<(), Box> { + // --- Fixture setup ----------------------------------------------- + // Reuses the same original PSBT and receiver output/fee parameters + // as the crate's own internal sender fixtures (see + // `payjoin::send::v1::test::create_psbt_context`), so the numbers + // are known to be internally consistent. + let original_psbt = PARSED_ORIGINAL_PSBT.clone(); + let receiver_script = original_psbt.unsigned_tx.output[1].script_pubkey.clone(); + let receiver_address = Address::from_script(&receiver_script, Network::Testnet)?; + + let pj_param = match PjParam::parse("https://example.com/")? { + payjoin::PjParam::V1(v1_param) => v1_param, + _ => panic!("expected a v1 PjParam"), + }; + + // --- Sender side: build and extract the v1 request ---------------- + let sender = + SenderBuilder::from_parts(original_psbt.clone(), &pj_param, &receiver_address, None) + .build_with_additional_fee(Amount::from_sat(182), Some(0), FeeRate::ZERO, true)?; + let (request, v1_context) = sender.create_v1_post_request(); + + // --- Transport (simulated) ----------------------------------------- + // In the hardware harness this is exactly the hop that goes over + // serial: raw bytes out from the sender device, raw bytes in on the + // receiver device. + let query = query_of(&request.url).to_string(); + let headers = FixedHeaders::for_body(&request.body); + + // --- Receiver side: process the request through to a signed proposal + let unchecked = UncheckedOriginalPayload::from_request(&request.body, &query, headers)?; + + let maybe_inputs_owned = unchecked.assume_interactive_receiver(); + + let maybe_inputs_seen = + maybe_inputs_owned.check_inputs_not_owned(&mut |_script| Ok(false))?; + + let outputs_unknown = + maybe_inputs_seen.check_no_inputs_seen_before(&mut |_outpoint| Ok(false))?; + + let receiver_script_for_closure = receiver_script.clone(); + let wants_outputs = outputs_unknown.identify_receiver_outputs(&mut move |script| { + Ok(script == &receiver_script_for_closure) + })?; + + // No output substitution for this test: keep the sender's outputs as-is. + let wants_inputs = wants_outputs.commit_outputs(); + + // Contribute a receiver-owned input, so this actually exercises a payjoin + // (combining inputs from both parties), not just a fee-bump pass-through. + let contributed_script = Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")? + .require_network(Network::Testnet)? + .script_pubkey(); + let psbtin = bitcoin::psbt::Input { + witness_utxo: Some(bitcoin::TxOut { + value: Amount::from_sat(50_000), + script_pubkey: contributed_script.clone(), + }), + ..Default::default() + }; + let txin = bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: bitcoin::Txid::from_str(&"11".repeat(32))?, + vout: 0, + }, + ..Default::default() + }; + let input_pair = payjoin::receive::InputPair::new(txin, psbtin, None) + .expect("input pair should be valid"); + + let wants_fee_range = wants_inputs.contribute_inputs(vec![input_pair])?.commit_inputs(); + + let provisional_proposal = wants_fee_range.apply_fee_range(None, None)?; + + // No additional receiver-owned inputs beyond the one just + // contributed need signing here; return the PSBT unchanged. + let contributed_script_for_finalize = contributed_script.clone(); + let payjoin_proposal = provisional_proposal.finalize_proposal(|psbt| { + let mut signed_psbt = psbt.clone(); + for input in signed_psbt.inputs.iter_mut() { + let is_contributed = input + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey == contributed_script_for_finalize) + .unwrap_or(false); + if is_contributed { + let mut witness = bitcoin::Witness::new(); + witness.push(vec![0u8; 71]); // dummy signature + witness.push(vec![0u8; 33]); // dummy pubkey + input.final_script_witness = Some(witness); + } + } + Ok(signed_psbt) + })?; + + let proposal_psbt = payjoin_proposal.psbt().clone(); + + // --- Transport back to sender (simulated) -------------------------- + let response_bytes = base64::engine::general_purpose::STANDARD + .encode(proposal_psbt.serialize()) + .into_bytes(); + + // --- Sender side: process the response, finalize the PSBT ---------- + let final_psbt = v1_context.process_response(&response_bytes)?; + + // process_proposal legitimately reconciles the receiver's proposal + // against the sender's own original PSBT (e.g. restoring + // redeem_script metadata the receiver didn't need to echo back), so + // exact equality with proposal_psbt isn't the right invariant. + // Matching txids confirms the same transaction round-tripped + // through the whole v1 flow. + assert_eq!(final_psbt.unsigned_tx.compute_txid(), proposal_psbt.unsigned_tx.compute_txid()); + // The receiver actually contributed an input: this is a real + // payjoin (combined UTXOs), not just a fee-bump pass-through. + assert_eq!(final_psbt.unsigned_tx.input.len(), original_psbt.unsigned_tx.input.len() + 1); + + Ok(()) + } +} + +#[cfg(feature = "v2-ohttp")] +mod v2 { + use std::cell::RefCell; + use std::str::FromStr; + + use bitcoin::{Address, FeeRate, Network}; + use ohttp::hpke::{Aead, Kdf, Kem}; + use ohttp::{KeyId, SymmetricSuite}; + use payjoin::persist::{InMemoryPersister, OptionalTransitionOutcome}; + use payjoin::receive::v2::ReceiverBuilder; + use payjoin::send::v2::SenderBuilder; + use payjoin::{OhttpKeys, Request, Uri}; + use payjoin_test_utils::PARSED_ORIGINAL_PSBT; + + /// Minimal in-memory stand-in for the Payjoin directory + OHTTP relay. + /// Holds at most one pending message ("the mailbox"), matching the + /// single session this test exercises. + struct FakeDirectory { + ohttp_keys: ohttp::KeyConfig, + mailbox: RefCell>>, + } + + impl FakeDirectory { + fn new(ohttp_keys: ohttp::KeyConfig) -> Self { + Self { ohttp_keys, mailbox: RefCell::new(None) } + } + + /// Handle one OHTTP-encapsulated round trip: decapsulate the + /// request, route GET/POST against the mailbox, and encapsulate + /// the response. + /// + /// NOTE: decapsulating twice for the same request (once to measure + /// the padding overhead, once to get the response context actually + /// used) mirrors the pattern in the crate's own internal test + /// helper (`ohttp_response_for` in `src/core/receive/v2/mod.rs`), + /// not something invented for this test. + fn round_trip(&self, req_body: &[u8]) -> Vec { + let server = ohttp::Server::new(self.ohttp_keys.clone()) + .expect("test OHTTP server should be valid"); + + let (bhttp_bytes, probe_response) = + server.decapsulate(req_body).expect("request should decapsulate"); + let response_overhead = + probe_response.encapsulate(&[]).expect("probe should encrypt").len(); + + let mut cursor = std::io::Cursor::new(&bhttp_bytes); + // NOTE: unconfirmed API surface. If this doesn't compile, check + // the bhttp crate's Message/Control accessors locally (`cargo + // doc --open -p bhttp`) for the right way to read the request + // method. + let request: bhttp::Message = + bhttp::Message::read_bhttp(&mut cursor).expect("bhttp request should parse"); + let is_post = request.control().method() == Some(b"POST".as_slice()); + + let (status, body): (u16, Vec) = if is_post { + *self.mailbox.borrow_mut() = Some(request.content().to_vec()); + (200, Vec::new()) + } else { + match self.mailbox.borrow_mut().take() { + Some(body) => (200, body), + None => (202, Vec::new()), + } + }; + + let (_, server_response) = + server.decapsulate(req_body).expect("request should decapsulate again"); + let mut response_message = bhttp::Message::response( + bhttp::StatusCode::try_from(status).expect("valid status"), + ); + response_message.write_content(&body); + + let mut bhttp_response = + vec![0u8; payjoin::directory::ENCAPSULATED_MESSAGE_BYTES - response_overhead]; + response_message + .write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_response.as_mut_slice()) + .expect("bhttp response should encode"); + server_response.encapsulate(&bhttp_response).expect("response should encrypt") + } + } + + fn test_ohttp_keys() -> (OhttpKeys, ohttp::KeyConfig) { + let symmetric = vec![SymmetricSuite::new(Kdf::HkdfSha256, Aead::ChaCha20Poly1305)]; + let key_config: KeyId = 1; + let config = ohttp::KeyConfig::new(key_config, Kem::K256Sha256, symmetric) + .expect("test OHTTP key config should be valid"); + let encoded = config.encode().expect("test OHTTP key config should encode"); + let ohttp_keys = + OhttpKeys::decode(&encoded).expect("test OHTTP key config should decode back"); + (ohttp_keys, config) + } + + #[test] + fn v2_round_trip_sender_and_receiver() -> Result<(), Box> { + let (ohttp_keys, ohttp_key_config) = test_ohttp_keys(); + let directory = FakeDirectory::new(ohttp_key_config); + let directory_url = "https://example-directory.test"; + let ohttp_relay_url = "https://example-relay.test"; + + let receiver_script = PARSED_ORIGINAL_PSBT.unsigned_tx.output[1].script_pubkey.clone(); + let receiver_address = Address::from_script(&receiver_script, Network::Testnet)?; + + // --- Receiver: start a session and poll (nothing posted yet) ------- + let recv_persister = InMemoryPersister::default(); + let session = ReceiverBuilder::new(receiver_address, directory_url, ohttp_keys)? + .build() + .save(&recv_persister)?; + + let (req, ctx) = session.create_poll_request(ohttp_relay_url)?; + let response_bytes = directory.round_trip(&req.body); + let outcome = session.process_response(&response_bytes, ctx).save(&recv_persister)?; + let session = match outcome { + OptionalTransitionOutcome::Stasis(current_state) => current_state, + OptionalTransitionOutcome::Progress(_) => + panic!("should still be waiting on the sender"), + }; + + // --- Sender: build and post the original PSBT ----------------------- + let pj_uri = Uri::from_str(&session.pj_uri().to_string()) + .map_err(|e| e.to_string())? + .assume_checked() + .check_pj_supported() + .map_err(|e| e.to_string())?; + + let send_persister = InMemoryPersister::default(); + let req_ctx = SenderBuilder::new(PARSED_ORIGINAL_PSBT.clone(), pj_uri) + .build_recommended(FeeRate::BROADCAST_MIN)? + .save(&send_persister)?; + + let (Request { body, .. }, send_ctx) = req_ctx.create_v2_post_request(ohttp_relay_url)?; + let post_response = directory.round_trip(&body); + let req_ctx = req_ctx.process_response(&post_response, send_ctx).save(&send_persister)?; + + // --- Receiver: poll again, this time the original PSBT is waiting -- + let (req, ctx) = session.create_poll_request(ohttp_relay_url)?; + let response_bytes = directory.round_trip(&req.body); + let outcome = session.process_response(&response_bytes, ctx).save(&recv_persister)?; + let proposal = match outcome { + OptionalTransitionOutcome::Progress(proposal) => proposal, + OptionalTransitionOutcome::Stasis(_) => panic!("proposal should have arrived"), + }; + + // --- Receiver: run it through the same typestate chain as the v1 test + let proposal = proposal.assume_interactive_receiver().save(&recv_persister)?; + let maybe_inputs_seen = + proposal.check_inputs_not_owned(&mut |_script| Ok(false)).save(&recv_persister)?; + let outputs_unknown = maybe_inputs_seen + .check_no_inputs_seen_before(&mut |_outpoint| Ok(false)) + .save(&recv_persister)?; + let wants_outputs = outputs_unknown + .identify_receiver_outputs(&mut move |script| Ok(script == &receiver_script)) + .save(&recv_persister)?; + let wants_inputs = wants_outputs.commit_outputs().save(&recv_persister)?; + + // Contribute a receiver-owned input, so this actually exercises a payjoin + // (combining inputs from both parties), not just a fee-bump pass-through. + let contributed_script = Address::from_str("tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4")? + .require_network(Network::Testnet)? + .script_pubkey(); + let psbtin = bitcoin::psbt::Input { + witness_utxo: Some(bitcoin::TxOut { + value: bitcoin::Amount::from_sat(50_000), + script_pubkey: contributed_script.clone(), + }), + ..Default::default() + }; + let txin = bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: bitcoin::Txid::from_str(&"11".repeat(32))?, + vout: 0, + }, + ..Default::default() + }; + let input_pair = payjoin::receive::InputPair::new(txin, psbtin, None) + .expect("input pair should be valid"); + let wants_fee_range = wants_inputs + .contribute_inputs(vec![input_pair])? + .commit_inputs() + .save(&recv_persister)?; + let provisional_proposal = + wants_fee_range.apply_fee_range(None, None).save(&recv_persister)?; + let contributed_script_for_finalize = contributed_script.clone(); + let payjoin_proposal = provisional_proposal + .finalize_proposal(|psbt| { + let mut signed_psbt = psbt.clone(); + for input in signed_psbt.inputs.iter_mut() { + let is_contributed = input + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey == contributed_script_for_finalize) + .unwrap_or(false); + if is_contributed { + let mut witness = bitcoin::Witness::new(); + witness.push(vec![0u8; 71]); // dummy signature + witness.push(vec![0u8; 33]); // dummy pubkey + input.final_script_witness = Some(witness); + } + } + Ok(signed_psbt) + }) + .save(&recv_persister)?; + + // --- Receiver: post the finished proposal back ----------------------- + let (req, ctx) = payjoin_proposal.create_post_request(ohttp_relay_url)?; + let response_bytes = directory.round_trip(&req.body); + payjoin_proposal.process_response(&response_bytes, ctx).save(&recv_persister)?; + + // --- Sender: poll for and finalize the proposal ----------------------- + let (Request { body, .. }, ohttp_ctx) = req_ctx.create_poll_request(ohttp_relay_url)?; + let response_bytes = directory.round_trip(&body); + let final_outcome = + req_ctx.process_response(&response_bytes, ohttp_ctx).save(&send_persister)?; + let final_proposal_psbt = match final_outcome { + OptionalTransitionOutcome::Progress(psbt) => psbt, + OptionalTransitionOutcome::Stasis(_) => panic!("sender should have the final proposal"), + }; + + assert!(final_proposal_psbt.unsigned_tx.output.len() >= 2); + assert_eq!( + final_proposal_psbt.unsigned_tx.input.len(), + PARSED_ORIGINAL_PSBT.unsigned_tx.input.len() + 1 + ); + Ok(()) + } +} From 1286678fdce480a8b3e0aaba268392badadf479e Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Fri, 31 Jul 2026 16:59:11 -0300 Subject: [PATCH 8/9] Fix no_std feature gating for URI types Align cfg gates for Uri/PjUri and their bitcoin_uri-backed adapters with the crate's actual feature dependencies (std, v1, v2-ohttp), fixing build failures under alloc-only and v1-only configurations that were not caught until running check-nostd/check-nostd-v1. --- payjoin/src/core/mod.rs | 2 + payjoin/src/core/receive/v1/mod.rs | 2 +- payjoin/src/core/receive/v2/session.rs | 4 +- payjoin/src/core/send/v2/mod.rs | 10 +--- payjoin/src/core/uri/error.rs | 13 +++-- payjoin/src/core/uri/mod.rs | 74 ++++++++++++++++---------- payjoin/src/core/uri/v2.rs | 5 +- payjoin/src/directory.rs | 2 +- 8 files changed, 67 insertions(+), 45 deletions(-) diff --git a/payjoin/src/core/mod.rs b/payjoin/src/core/mod.rs index 12dd90172..6242d47ed 100644 --- a/payjoin/src/core/mod.rs +++ b/payjoin/src/core/mod.rs @@ -31,6 +31,8 @@ pub mod uri; pub use uri::PjUri; #[cfg(feature = "std")] pub use uri::Uri; +#[cfg(feature = "std")] +pub use uri::UriParseError; pub use uri::{PjParam, PjParseError}; pub(crate) mod error_codes; diff --git a/payjoin/src/core/receive/v1/mod.rs b/payjoin/src/core/receive/v1/mod.rs index cae3de6ce..63e95f17d 100644 --- a/payjoin/src/core/receive/v1/mod.rs +++ b/payjoin/src/core/receive/v1/mod.rs @@ -51,7 +51,7 @@ pub trait Headers { } #[cfg(feature = "std")] -pub fn build_v1_pj_uri<'a>( +pub fn build_v1_pj_uri( address: &bitcoin::Address, endpoint: impl IntoUrl, output_substitution: OutputSubstitution, diff --git a/payjoin/src/core/receive/v2/session.rs b/payjoin/src/core/receive/v2/session.rs index 1154ae405..252dc2390 100644 --- a/payjoin/src/core/receive/v2/session.rs +++ b/payjoin/src/core/receive/v2/session.rs @@ -1219,9 +1219,9 @@ mod tests { let binding = SessionHistory { events }; let uri = binding.pj_uri(); - assert_ne!(uri.extras.pj_param.endpoint().as_str(), EXAMPLE_URL); + assert_ne!(uri.extras().pj_param.endpoint().as_str(), EXAMPLE_URL); #[cfg(feature = "v1")] - assert_eq!(uri.extras.output_substitution, OutputSubstitution::Disabled); + assert_eq!(uri.extras().output_substitution, OutputSubstitution::Disabled); Ok(()) } diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index def5fd1d4..3dda9a23c 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -28,10 +28,6 @@ //! Note: Even fresh requests may be linkable via metadata (e.g. client IP, request timing), //! but request reuse makes correlation trivial for the relay. -#[cfg(feature = "v2-ohttp")] -use bitcoin::hashes::sha256; -#[cfg(feature = "v2-ohttp")] -use bitcoin::hashes::Hash; #[cfg(feature = "v2-ohttp")] use bitcoin::Address; pub use error::{CreateRequestError, DecapsulationError}; @@ -566,10 +562,8 @@ impl Sender { // TODO unify with receiver's fn short_id_from_pubkey use crate::ohttp::ohttp_encapsulate; - let hash = sha256::Hash::hash( - &HpkeKeyPair::from_secret_key(&self.session_context.reply_key) - .public_key() - .to_compressed_bytes(), + let mailbox: ShortId = ShortId::from( + HpkeKeyPair::from_secret_key(&self.session_context.reply_key).public_key(), ); let url = Url::parse(self.session_context.pj_param.endpoint().as_str()) .expect("Could not parse url") diff --git a/payjoin/src/core/uri/error.rs b/payjoin/src/core/uri/error.rs index 6e093b28d..ee0fd0e34 100644 --- a/payjoin/src/core/uri/error.rs +++ b/payjoin/src/core/uri/error.rs @@ -10,9 +10,11 @@ pub struct PjParseError(pub(super) InternalPjParseError); /// /// This wraps the underlying `bitcoin_uri` parse error so that a breaking change /// in that crate does not force a breaking change in this crate's public API. +#[cfg(feature = "std")] #[derive(Debug)] pub struct UriParseError(InternalUriParseError); +#[cfg(feature = "std")] #[derive(Debug)] enum InternalUriParseError { /// The BIP21 URI itself (address, amount, or standard parameters) is invalid. @@ -24,6 +26,7 @@ enum InternalUriParseError { PayjoinParams(PjParseError), } +#[cfg(feature = "std")] impl UriParseError { /// Erases the foreign `bitcoin_uri` parse error into this opaque type. /// @@ -48,8 +51,9 @@ impl UriParseError { } } -impl std::fmt::Display for UriParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +#[cfg(feature = "std")] +impl fmt::Display for UriParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match &self.0 { InternalUriParseError::Bip21(e) => write!(f, "Invalid BIP21 URI: {e}"), InternalUriParseError::PayjoinParams(e) => write!(f, "Invalid payjoin parameters: {e}"), @@ -57,8 +61,9 @@ impl std::fmt::Display for UriParseError { } } -impl std::error::Error for UriParseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { +#[cfg(feature = "std")] +impl error::Error for UriParseError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { match &self.0 { InternalUriParseError::Bip21(e) => Some(e), InternalUriParseError::PayjoinParams(e) => Some(e), diff --git a/payjoin/src/core/uri/mod.rs b/payjoin/src/core/uri/mod.rs index 966f60009..16907adf5 100644 --- a/payjoin/src/core/uri/mod.rs +++ b/payjoin/src/core/uri/mod.rs @@ -1,24 +1,30 @@ //! Payjoin URI parsing and validation +#[cfg(feature = "std")] use alloc::borrow::Cow; +#[cfg(feature = "std")] use alloc::boxed::Box; #[cfg(any(feature = "v1", feature = "v2-ohttp"))] use alloc::fmt; #[cfg(any(feature = "v1", feature = "v2-ohttp"))] use alloc::string::{String, ToString}; -#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] +#[cfg(feature = "std")] use alloc::vec; -#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] use alloc::vec::Vec; +#[cfg(feature = "std")] use core::str::FromStr; +#[cfg(feature = "std")] use bitcoin::address::{NetworkChecked, NetworkUnchecked, NetworkValidation}; +#[cfg(feature = "std")] use bitcoin::{Address, Amount}; -pub use error::{PjParseError, UriParseError}; +pub use error::PjParseError; +#[cfg(feature = "std")] +pub use error::UriParseError; #[cfg(feature = "v2-ohttp")] pub(crate) use crate::directory::ShortId; use crate::output_substitution::OutputSubstitution; -#[cfg(feature = "std")] #[cfg(any(feature = "v1", feature = "v2-ohttp"))] use crate::uri::error::InternalPjParseError; @@ -148,10 +154,12 @@ impl PayjoinExtras { /// /// The URI is always owned, so it carries no lifetime parameter. #[derive(Clone, Debug)] +#[cfg(feature = "std")] pub struct Uri( bitcoin_uri::Uri<'static, NetVal, MaybePayjoinExtrasAdapter>, ); +#[cfg(feature = "std")] impl Uri { /// The address the URI pays to. pub fn address(&self) -> &Address { &self.0.address } @@ -173,6 +181,7 @@ impl Uri { pub fn extras(&self) -> &MaybePayjoinExtras { &self.0.extras.0 } } +#[cfg(feature = "std")] impl Uri { /// Marks the URI's address as validated without checking the network. pub fn assume_checked(self) -> Uri { Uri(self.0.assume_checked()) } @@ -186,6 +195,7 @@ impl Uri { } } +#[cfg(feature = "std")] impl Uri { /// Converts this URI into a [`PjUri`] if it supports payjoin. /// @@ -217,6 +227,7 @@ impl Uri { } } +#[cfg(feature = "std")] impl FromStr for Uri { type Err = UriParseError; @@ -227,18 +238,21 @@ impl FromStr for Uri { } } +#[cfg(feature = "std")] impl TryFrom<&str> for Uri { type Error = UriParseError; fn try_from(s: &str) -> Result { s.parse() } } +#[cfg(feature = "std")] impl TryFrom for Uri { type Error = UriParseError; fn try_from(s: String) -> Result { s.parse() } } +#[cfg(feature = "std")] impl fmt::Display for Uri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } @@ -248,8 +262,10 @@ impl fmt::Display for Uri { /// Obtained from [`Uri::check_pj_supported`]. Like [`Uri`], this newtype /// insulates the public API from [`bitcoin_uri`] and is always owned. #[derive(Clone, Debug)] +#[cfg(feature = "std")] pub struct PjUri(bitcoin_uri::Uri<'static, NetworkChecked, PayjoinExtrasAdapter>); +#[cfg(feature = "std")] impl PjUri { /// Builds a payjoin URI from a checked address and validated payjoin parameters. pub(crate) fn from_extras(address: Address, extras: PayjoinExtras) -> Self { @@ -277,8 +293,15 @@ impl PjUri { /// The validated payjoin parameters carried by the URI. pub fn extras(&self) -> &PayjoinExtras { &self.0.extras.0 } + + /// Overrides the output substitution preference carried by the URI. + #[cfg(test)] + pub(crate) fn set_output_substitution(&mut self, output_substitution: OutputSubstitution) { + self.0.extras.0.output_substitution = output_substitution; + } } +#[cfg(feature = "std")] impl fmt::Display for PjUri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } @@ -287,14 +310,17 @@ impl fmt::Display for PjUri { /// trait impls, keeping them off the public [`MaybePayjoinExtras`] type so that /// `bitcoin_uri` stays out of this crate's semver surface. #[derive(Clone, Debug)] +#[cfg(feature = "std")] pub(crate) struct MaybePayjoinExtrasAdapter(pub(crate) MaybePayjoinExtras); /// Private adapter that carries the `bitcoin_uri` serialization trait impl for /// [`PayjoinExtras`], keeping it off the public type. #[derive(Clone, Debug)] +#[cfg(feature = "std")] pub(crate) struct PayjoinExtrasAdapter(pub(crate) PayjoinExtras); /// Serializes the payjoin BIP21 query parameters (`pj` and optional `pjos`). +#[cfg(any(feature = "v1", feature = "v2-ohttp"))] fn serialize_payjoin_params(extras: &PayjoinExtras) -> Vec<(&'static str, String)> { let mut params = Vec::with_capacity(2); if extras.output_substitution == OutputSubstitution::Disabled { @@ -304,12 +330,12 @@ fn serialize_payjoin_params(extras: &PayjoinExtras) -> Vec<(&'static str, String params } -#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] impl bitcoin_uri::de::DeserializationError for MaybePayjoinExtrasAdapter { type Error = PjParseError; } -#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] impl bitcoin_uri::de::DeserializeParams<'_> for MaybePayjoinExtrasAdapter { type DeserializationState = DeserializationState; } @@ -321,7 +347,7 @@ pub(crate) struct DeserializationState { pjos: Option, } -#[cfg(feature = "v2-ohttp")] +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] impl bitcoin_uri::SerializeParams for &MaybePayjoinExtrasAdapter { type Key = &'static str; type Value = String; @@ -335,7 +361,7 @@ impl bitcoin_uri::SerializeParams for &MaybePayjoinExtrasAdapter { } } -#[cfg(any(feature = "v1", feature = "v2-ohttp"))] +#[cfg(all(feature = "std", any(feature = "v1", feature = "v2-ohttp")))] impl bitcoin_uri::SerializeParams for &PayjoinExtrasAdapter { type Key = &'static str; type Value = String; @@ -407,10 +433,8 @@ pub(crate) fn pj_uri(uri: &str) -> PjUri { #[cfg(test)] mod tests { - use std::convert::TryFrom; - #[cfg(all(feature = "std", feature = "v1"))] - use bitcoin_uri::SerializeParams; + use std::convert::TryFrom; use super::*; @@ -495,7 +519,7 @@ mod tests { %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC" ) .unwrap() - .extras + .extras() .pj_is_supported(), "Uri expected a success with a well formatted pj extras, but it failed" ); @@ -526,23 +550,19 @@ mod tests { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pjos=1&pj=HTTPS://EXAMPLE.COM/\ %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; - let pjuri = Uri::try_from(uri); + let err = Uri::try_from(uri).unwrap_err(); assert!(matches!( - pjuri, - Err(bitcoin_uri::de::Error::Extras(PjParseError( - InternalPjParseError::DuplicateParams("pjos") - ))) + err.payjoin_params().map(|e| &e.0), + Some(InternalPjParseError::DuplicateParams("pjos")) )); let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pjos=1&pj=HTTPS://EXAMPLE.COM/\ %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC&pj=HTTPS://EXAMPLE.COM/\ %23OH1QYPM5JXYNS754Y4R45QWE336QFX6ZR8DQGVQCULVZTV20TFVEYDMFQC"; - let pjuri = Uri::try_from(uri); + let err = Uri::try_from(uri).unwrap_err(); assert!(matches!( - pjuri, - Err(bitcoin_uri::de::Error::Extras(PjParseError( - InternalPjParseError::DuplicateParams("pj") - ))) + err.payjoin_params().map(|e| &e.0), + Some(InternalPjParseError::DuplicateParams("pj")) )); } @@ -558,13 +578,13 @@ mod tests { .check_pj_supported() .expect("Could not parse pj extras"); - pjuri.extras.output_substitution = OutputSubstitution::Disabled; + pjuri.set_output_substitution(OutputSubstitution::Disabled); assert!( pjuri.to_string().contains(expected_is_disabled), "Pj uri should contain param: {expected_is_disabled}, but it did not" ); - pjuri.extras.output_substitution = OutputSubstitution::Enabled; + pjuri.set_output_substitution(OutputSubstitution::Enabled); assert!( !pjuri.to_string().contains(expected_is_enabled), "Pj uri should elide param: {expected_is_enabled}, but it did not" @@ -577,7 +597,7 @@ mod tests { // pjos=0 should disable output substitution let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=0"; let parsed = Uri::try_from(uri).unwrap(); - match parsed.extras { + match parsed.extras() { MaybePayjoinExtras::Supported(extras) => assert_eq!(extras.output_substitution, OutputSubstitution::Disabled), _ => panic!("Expected Supported PayjoinExtras"), @@ -586,7 +606,7 @@ mod tests { // pjos=1 should allow output substitution let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com&pjos=1"; let parsed = Uri::try_from(uri).unwrap(); - match parsed.extras { + match parsed.extras() { MaybePayjoinExtras::Supported(extras) => assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), _ => panic!("Expected Supported PayjoinExtras"), @@ -595,7 +615,7 @@ mod tests { // Elided pjos=1 should allow output substitution let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://example.com"; let parsed = Uri::try_from(uri).unwrap(); - match parsed.extras { + match parsed.extras() { MaybePayjoinExtras::Supported(extras) => assert_eq!(extras.output_substitution, OutputSubstitution::Enabled), _ => panic!("Expected Supported PayjoinExtras"), diff --git a/payjoin/src/core/uri/v2.rs b/payjoin/src/core/uri/v2.rs index 83c80d5e2..316baced0 100644 --- a/payjoin/src/core/uri/v2.rs +++ b/payjoin/src/core/uri/v2.rs @@ -430,7 +430,7 @@ mod tests { use super::*; #[cfg(all(feature = "v1", feature = "v2"))] - use crate::{Uri, UriExt}; + use crate::Uri; #[test] fn test_ohttp_get_set() { @@ -608,7 +608,8 @@ mod tests { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pjos=0&pj=HTTPS://EXAMPLE.COM/missing_short_id\ %23oh1qypm5jxyns754y4r45qwe336qfx6zr8dqgvqculvztv20tfveydmfqc"; - let extras = Uri::try_from(uri).unwrap().extras; + let uri = Uri::try_from(uri).unwrap(); + let extras = uri.extras(); match extras { crate::uri::MaybePayjoinExtras::Supported(extras) => { assert!(matches!(extras.pj_param, crate::uri::PjParam::V1(_))); diff --git a/payjoin/src/directory.rs b/payjoin/src/directory.rs index 86fad847f..344da3d7a 100644 --- a/payjoin/src/directory.rs +++ b/payjoin/src/directory.rs @@ -83,7 +83,7 @@ impl From for ShortId { /// Derives the BIP 77 mailbox [`ShortId`] for an [`HpkePublicKey`](crate::HpkePublicKey), /// a truncated SHA256 hash of its compressed serialization. Sender and receiver /// derive mailbox IDs this way so both agree on a session's mailbox. -#[cfg(feature = "v2")] +#[cfg(feature = "v2-ohttp")] impl From<&crate::HpkePublicKey> for ShortId { fn from(key: &crate::HpkePublicKey) -> Self { use bitcoin::hashes::{sha256, Hash}; From 196d4fb9351bf7d9a7ac4749474ec296667ad66c Mon Sep 17 00:00:00 2001 From: Carlos Santos Date: Fri, 7 Aug 2026 16:21:33 -0300 Subject: [PATCH 9/9] Split embedded build into v1 and v2 CI steps Add a "Build embedded target v1" step alongside the existing v2 step so CI exercises both feature combinations (alloc,v1 and alloc,v2) against the thumbv7em-none-eabihf no_std target, matching the two combinations already covered by local check-nostd/check-nostd-v1 bacon jobs. Set CARGO_PROFILE_DEV_DEBUG=0 for both steps. secp256k1-sys's build script (via cc-rs) injects -fno-omit-frame-pointer and -mno-omit-leaf-frame-pointer for GCC when Cargo's DEBUG env var is set. The latter flag does not exist on arm-none-eabi-gcc, which only supports it on x86/AArch64, so the build fails whenever dependency resolution picks a cc version that adds this flag. This does not reproduce locally because a resolved Cargo.lock is not committed, so CI is exposed to any new cc release changing this behavior while a local checkout stays pinned to whatever cc version was first resolved. This only affects the payjoin core no_std compilation check in CI and has no effect on payjoin-no-std-harness firmware builds used to flash real hardware, which are built separately with debug symbols intact. --- .github/workflows/rust.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 37211ec73..7da856052 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -148,15 +148,17 @@ jobs: Embedded: name: Embedded build - runs-on: ubuntu-latest + env: + CARGO_PROFILE_DEV_DEBUG: "0" + runs-on: ubuntu-26.04 steps: - name: "Checkout repo" - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: "Use cache" uses: Swatinem/rust-cache@v2 - name: "Install nix" uses: DeterminateSystems/determinate-nix-action@main - - name: "Use nix cache" - uses: DeterminateSystems/magic-nix-cache-action@main - - name: "Build embedded target" + - name: "Build embedded target v2" + run: nix develop .#embedded -c cargo build -p payjoin --no-default-features --features "alloc,v2" --target thumbv7em-none-eabihf -Zbuild-std=core,alloc + - name: "Build embedded target v1" run: nix develop .#embedded -c cargo build -p payjoin --no-default-features --features "alloc,v2" --target thumbv7em-none-eabihf -Zbuild-std=core,alloc