Skip to content

Commit

Permalink
Handle receiver error with POST
Browse files Browse the repository at this point in the history
Recoverable errors are be shared with the Sender so that they
might recover rather than necessarily waiting for the session
to expire.
  • Loading branch information
DanGould committed Jan 13, 2025
1 parent eaf2398 commit f2c8b94
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 7 deletions.
35 changes: 29 additions & 6 deletions payjoin-cli/src/app/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use bitcoincore_rpc::RpcApi;
use payjoin::bitcoin::consensus::encode::serialize_hex;
use payjoin::bitcoin::psbt::Psbt;
use payjoin::bitcoin::{Amount, FeeRate};
use payjoin::receive::v2::Receiver;
use payjoin::receive::v2::{Receiver, UncheckedProposal};
use payjoin::send::v2::{Sender, SenderBuilder};
use payjoin::{bitcoin, Error, Uri};
use tokio::signal;
Expand Down Expand Up @@ -124,7 +124,7 @@ impl App {
println!("{}", pj_uri);

let mut interrupt = self.interrupt.clone();
let res = tokio::select! {
let receiver = tokio::select! {
res = self.long_poll_fallback(&mut session) => res,
_ = interrupt.changed() => {
println!("Interrupted. Call the `resume` command to resume all sessions.");
Expand All @@ -133,10 +133,14 @@ impl App {
}?;

println!("Fallback transaction received. Consider broadcasting this to get paid if the Payjoin fails:");
println!("{}", serialize_hex(&res.extract_tx_to_schedule_broadcast()));
let mut payjoin_proposal = self
.process_v2_proposal(res)
.map_err(|e| anyhow!("Failed to process proposal {}", e))?;
println!("{}", serialize_hex(&receiver.extract_tx_to_schedule_broadcast()));
let mut payjoin_proposal = match self.process_v2_proposal(receiver.clone()) {
Ok(proposal) => proposal,
Err(e) => {
handle_request_error(e, receiver, &self.config.ohttp_relay).await?;
unreachable!("handle_request_error always returns Err")
}
};
let (req, ohttp_ctx) = payjoin_proposal
.extract_v2_req()
.map_err(|e| anyhow!("v2 req extraction failed {}", e))?;
Expand Down Expand Up @@ -333,6 +337,25 @@ impl App {
}
}

/// Handle request error by sending an error response over the directory
async fn handle_request_error(
e: Error,
mut receiver: UncheckedProposal,
ohttp_relay: &payjoin::Url,
) -> Result<(), anyhow::Error> {
let (err_req, err_ctx) = receiver
.extract_err_req(e, ohttp_relay)
.map_err(|e| anyhow!("Failed to extract error request: {}", e))?;

let err_response = post_request(err_req).await?;

let err_bytes = err_response.bytes().await?;
receiver
.process_err_res(&err_bytes, err_ctx)
.map_err(|e| anyhow!("Failed to process error response: {}", e))?;
Err(anyhow!("Failed to process proposal"))
}

fn try_contributing_inputs(
payjoin: payjoin::receive::v2::WantsInputs,
bitcoind: &bitcoincore_rpc::Client,
Expand Down
13 changes: 12 additions & 1 deletion payjoin/src/receive/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@ pub enum Error {
/// To be returned as HTTP 400
BadRequest(RequestError),
// To be returned as HTTP 500
Server(Box<dyn error::Error>),
Server(Box<dyn error::Error + Send + Sync>),
}

impl Error {
pub fn to_json(&self) -> String {
match self {
Self::BadRequest(e) => e.to_string(),
Self::Server(_) =>
"{{ \"errorCode\": \"server-error\", \"message\": \"Internal server error\" }}"
.to_string(),
}
}
}

impl fmt::Display for Error {
Expand Down
5 changes: 5 additions & 0 deletions payjoin/src/receive/v2/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub(crate) enum InternalSessionError {
OhttpEncapsulation(OhttpEncapsulationError),
/// Unexpected response size
UnexpectedResponseSize(usize),
/// Unexpected status code
UnexpectedStatusCode(http::StatusCode),
}

impl fmt::Display for SessionError {
Expand All @@ -28,6 +30,8 @@ impl fmt::Display for SessionError {
size,
crate::ohttp::ENCAPSULATED_MESSAGE_BYTES
),
InternalSessionError::UnexpectedStatusCode(status) =>
write!(f, "Unexpected status code: {}", status),
}
}
}
Expand All @@ -38,6 +42,7 @@ impl error::Error for SessionError {
InternalSessionError::Expired(_) => None,
InternalSessionError::OhttpEncapsulation(e) => Some(e),
InternalSessionError::UnexpectedResponseSize(_) => None,
InternalSessionError::UnexpectedStatusCode(_) => None,
}
}
}
Expand Down
58 changes: 58 additions & 0 deletions payjoin/src/receive/v2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,64 @@ impl UncheckedProposal {
let inner = self.v1.assume_interactive_receiver();
MaybeInputsOwned { v1: inner, context: self.context }
}

/// Extract an OHTTP Encapsulated HTTP POST request to return
/// a Receiver Error Response
pub fn extract_err_req(
&mut self,
err: Error,
ohttp_relay: &Url,
) -> Result<(Request, ohttp::ClientResponse), SessionError> {
let subdir = self.subdir();
let (body, ohttp_ctx) = ohttp_encapsulate(
&mut self.context.ohttp_keys,
"POST",
subdir.as_str(),
Some(err.to_json().as_bytes()),
)
.map_err(InternalSessionError::OhttpEncapsulation)?;

let req = Request::new_v2(ohttp_relay.clone(), body);
Ok((req, ohttp_ctx))
}

/// Process an OHTTP Encapsulated HTTP POST Error response
/// to ensure it has been posted properly
pub fn process_err_res(
&mut self,
body: &[u8],
context: ohttp::ClientResponse,
) -> Result<(), SessionError> {
let response_array: &[u8; crate::ohttp::ENCAPSULATED_MESSAGE_BYTES] =
body.try_into().map_err(|_| {
SessionError::from(InternalSessionError::UnexpectedResponseSize(body.len()))
})?;
let response = ohttp_decapsulate(context, response_array)?;

match response.status() {
http::StatusCode::OK => Ok(()),
_ => Err(SessionError::from(InternalSessionError::UnexpectedStatusCode(
response.status(),
))),
}
}

/// The subdirectory for this Payjoin receiver session.
/// It consists of a directory URL and the session ShortID in the path.
pub fn subdir(&self) -> Url {
let mut url = self.context.directory.clone();
{
let mut path_segments =
url.path_segments_mut().expect("Payjoin Directory URL cannot be a base");
path_segments.push(&self.id().to_string());
}
url
}

/// The per-session identifier
pub fn id(&self) -> ShortId {
sha256::Hash::hash(&self.context.s.public_key().to_compressed_bytes()).into()
}
}

/// Typestate to validate that the Original PSBT has no receiver-owned inputs.
Expand Down

0 comments on commit f2c8b94

Please sign in to comment.