diff --git a/Cargo.lock b/Cargo.lock index 17dd0e50..2ef922f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1846,10 +1846,13 @@ dependencies = [ "bitcoind", "env_logger", "hyper", + "hyper-rustls", "log", "ohttp", "payjoin", + "rcgen", "reqwest", + "rustls", "sqlx", "testcontainers", "testcontainers-modules", diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index a178312d..b36bdd4c 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -42,3 +42,7 @@ url = "2.2.1" [dev-dependencies] bitcoind = { version = "0.31.1", features = ["0_21_2"] } +tokio = { version = "1.12.0", features = ["full"] } + +[profile.v2] +inherit = "dev" diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 99e70de8..bbb3dc82 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -10,12 +10,20 @@ use bitcoincore_rpc::jsonrpc::serde_json; use bitcoincore_rpc::RpcApi; use clap::ArgMatches; use config::{Config, File, FileFormat}; +#[cfg(not(feature = "v2"))] use hyper::service::{make_service_fn, service_fn}; +#[cfg(not(feature = "v2"))] use hyper::{Body, Method, Request, Response, Server, StatusCode}; -use payjoin::bitcoin; use payjoin::bitcoin::psbt::Psbt; -use payjoin::receive::{Error, ProvisionalProposal}; +use payjoin::bitcoin::{self, base64}; +#[cfg(feature = "v2")] +use payjoin::receive::v2; +use payjoin::receive::Error; +#[cfg(not(feature = "v2"))] +use payjoin::receive::{PayjoinProposal, UncheckedProposal}; +use payjoin::send::RequestContext; use serde::{Deserialize, Serialize}; +#[cfg(feature = "v2")] use tokio::task::spawn_blocking; #[cfg(feature = "danger-local-https")] @@ -50,10 +58,158 @@ impl App { .with_context(|| "Failed to connect to bitcoind") } + #[cfg(feature = "v2")] pub async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()> { + let req_ctx = self.create_pj_request(bip21, fee_rate)?; + log::debug!("Awaiting response"); + let res = self.long_poll_post(req_ctx).await?; + self.process_pj_response(res)?; + Ok(()) + } + + #[cfg(not(feature = "v2"))] + pub async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()> { + let (req, ctx) = self.create_pj_request(bip21, fee_rate)?.extract_v1()?; + let http = http_agent()?; + let body = String::from_utf8(req.body.clone()).unwrap(); + println!("Sending fallback request to {}", &req.url); + let response = http + .post(req.url.as_str()) + .set("Content-Type", "text/plain") + .send_string(&body.clone()) + .with_context(|| "HTTP request failed")?; + let fallback_tx = Psbt::from_str(&body) + .map_err(|e| anyhow!("Failed to load PSBT from base64: {}", e))? + .extract_tx(); + println!("Sent fallback transaction txid: {}", fallback_tx.txid()); + println!( + "Sent fallback transaction hex: {:#}", + payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) + ); + let psbt = ctx + .process_response(&mut response.into_reader()) + .map_err(|e| anyhow!("Failed to process response {}", e))?; + + self.process_pj_response(psbt)?; + Ok(()) + } + + #[cfg(feature = "v2")] + pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { + use v2::Enroller; + + let mut enroller = Enroller::from_relay_config( + &self.config.pj_endpoint, + &self.config.ohttp_config, + &self.config.ohttp_proxy, + ); + let (req, ctx) = enroller.extract_req()?; + log::debug!("Enrolling receiver"); + let ohttp_response = spawn_blocking(move || { + let http = http_agent()?; + http.post(req.url.as_ref()).send_bytes(&req.body).with_context(|| "HTTP request failed") + }) + .await??; + + let mut enrolled = enroller + .process_res(ohttp_response.into_reader(), ctx) + .map_err(|_| anyhow!("Enrollment failed"))?; + log::debug!("Enrolled receiver"); + + let pj_uri_string = + self.construct_payjoin_uri(amount_arg, Some(&enrolled.fallback_target()))?; + println!( + "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", + self.config.pj_host + ); + println!("{}", pj_uri_string); + + log::debug!("Awaiting proposal"); + let res = self.long_poll_fallback(&mut enrolled).await?; + log::debug!("Received request"); + let payjoin_proposal = self + .process_v2_proposal(res) + .map_err(|e| anyhow!("Failed to process proposal {}", e))?; + log::debug!("Posting payjoin back"); + let (req, ohttp_ctx) = payjoin_proposal + .extract_v2_req() + .map_err(|e| anyhow!("v2 req extraction failed {}", e))?; + let http = http_agent()?; + let res = http.post(req.url.as_str()).send_bytes(&req.body)?; + let mut buf = Vec::new(); + let _ = res.into_reader().read_to_end(&mut buf)?; + let res = payjoin_proposal.deserialize_res(buf, ohttp_ctx); + log::debug!("Received response {:?}", res); + Ok(()) + } + + #[cfg(not(feature = "v2"))] + pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { + let pj_uri_string = self.construct_payjoin_uri(amount_arg, None)?; + println!( + "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", + self.config.pj_host + ); + println!("{}", pj_uri_string); + + self.start_http_server().await?; + Ok(()) + } + + #[cfg(feature = "v2")] + async fn long_poll_post(&self, req_ctx: payjoin::send::RequestContext<'_>) -> Result { + loop { + let (req, ctx) = req_ctx.extract_v2(&self.config.ohttp_proxy)?; + println!("Sending fallback request to {}", &req.url); + let http = http_agent()?; + let response = spawn_blocking(move || { + http.post(req.url.as_ref()) + .set("Content-Type", "text/plain") + .send_bytes(&req.body) + .with_context(|| "HTTP request failed") + }) + .await??; + + println!("Sent fallback transaction"); + let psbt = ctx.process_response(&mut response.into_reader())?; + if let Some(psbt) = psbt { + return Ok(psbt); + } else { + log::info!("No response yet for POST payjoin request, retrying some seconds"); + std::thread::sleep(std::time::Duration::from_secs(5)); + } + } + } + + #[cfg(feature = "v2")] + async fn long_poll_fallback( + &self, + enrolled: &mut payjoin::receive::v2::Enrolled, + ) -> Result { + loop { + let (req, context) = + enrolled.extract_req().map_err(|_| anyhow!("Failed to extract request"))?; + log::debug!("GET fallback_psbt"); + let http = http_agent()?; + let ohttp_response = + spawn_blocking(move || http.post(req.url.as_str()).send_bytes(&req.body)).await??; + + let proposal = enrolled + .process_res(ohttp_response.into_reader(), context) + .map_err(|_| anyhow!("GET fallback failed"))?; + log::debug!("got response"); + match proposal { + Some(proposal) => break Ok(proposal), + None => std::thread::sleep(std::time::Duration::from_secs(5)), + } + } + } + + fn create_pj_request<'a>(&self, bip21: &'a str, fee_rate: &f32) -> Result> { let uri = payjoin::Uri::try_from(bip21) - .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))? - .assume_checked(); + .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?; + + let uri = uri.assume_checked(); let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?; @@ -90,31 +246,16 @@ impl App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - let fallback_tx = psbt.clone().extract_tx(); - let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) + let req_ctx = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) .with_context(|| "Failed to build payjoin request")? .build_recommended(fee_rate) - .with_context(|| "Failed to build payjoin request")? - .extract_v1()?; + .with_context(|| "Failed to build payjoin request")?; - let http = http_agent()?; - println!("Sending fallback request to {}", &req.url); - let response = spawn_blocking(move || { - http.post(req.url.as_str()) - .set("Content-Type", "text/plain") - .send_bytes(&req.body) - .with_context(|| "HTTP request failed") - }) - .await??; - println!("Sent fallback transaction txid: {}", fallback_tx.txid()); - println!( - "Sent fallback transaction hex: {:#}", - payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) - ); + Ok(req_ctx) + } + + fn process_pj_response(&self, psbt: Psbt) -> Result { // TODO display well-known errors and log::debug the rest - let psbt = ctx - .process_response(&mut response.into_reader()) - .with_context(|| "Failed to process response")?; log::debug!("Proposed psbt: {:#?}", psbt); let psbt = self .bitcoind()? @@ -132,30 +273,12 @@ impl App { .send_raw_transaction(&tx) .with_context(|| "Failed to send raw transaction")?; println!("Payjoin sent: {}", txid); - Ok(()) + Ok(txid) } - pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { - use payjoin::Uri; - - let pj_receiver_address = self.bitcoind()?.get_new_address(None, None)?.assume_checked(); - let amount = Amount::from_sat(amount_arg.parse()?); - let pj_uri_string = format!( - "{}?amount={}&pj={}", - pj_receiver_address.to_qr_uri(), - amount.to_btc(), - self.config.pj_endpoint - ); - // check that the URI is corrctly formatted - let _pj_uri = Uri::from_str(&pj_uri_string) - .map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))? - .assume_checked(); + #[cfg(not(feature = "v2"))] + async fn start_http_server(self) -> Result<()> { let bind_addr: SocketAddr = self.config.pj_host.parse()?; - println!( - "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", - self.config.pj_host - ); - println!("{}", pj_uri_string); #[cfg(feature = "danger-local-https")] let server = { @@ -183,9 +306,9 @@ impl App { #[cfg(not(feature = "danger-local-https"))] let server = Server::bind(&bind_addr); - + let app = self.clone(); let make_svc = make_service_fn(|_| { - let app = self.clone(); + let app = app.clone(); async move { let handler = move |req| app.clone().handle_web_request(req); Ok::<_, hyper::Error>(service_fn(handler)) @@ -195,6 +318,34 @@ impl App { Ok(()) } + fn construct_payjoin_uri( + &self, + amount_arg: &str, + fallback_target: Option<&str>, + ) -> Result { + let pj_receiver_address = self.bitcoind()?.get_new_address(None, None)?.assume_checked(); + let amount = Amount::from_sat(amount_arg.parse()?); + let pj_part = match fallback_target { + Some(target) => target, + None => self.config.pj_endpoint.as_str(), + }; + + let pj_uri_string = format!( + "{}?amount={}&pj={}&ohttp={}", + pj_receiver_address.to_qr_uri(), + amount.to_btc(), + pj_part, + self.config.ohttp_config, + ); + + // to check uri validity + let _pj_uri = payjoin::Uri::from_str(&pj_uri_string) + .map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))?; + + Ok(pj_uri_string) + } + + #[cfg(not(feature = "v2"))] async fn handle_web_request(self, req: Request) -> Result> { log::debug!("Received request: {:?}", req); let mut response = match (req.method(), req.uri().path()) { @@ -238,6 +389,7 @@ impl App { Ok(response) } + #[cfg(not(feature = "v2"))] fn handle_get_bip21(&self, amount: Option) -> Result, Error> { let address = self .bitcoind() @@ -262,6 +414,7 @@ impl App { Ok(Response::new(Body::from(uri_string))) } + #[cfg(not(feature = "v2"))] async fn handle_payjoin_post(&self, req: Request) -> Result, Error> { let (parts, body) = req.into_parts(); let headers = Headers(&parts.headers); @@ -272,6 +425,15 @@ impl App { let proposal = payjoin::receive::UncheckedProposal::from_request(body, query_string, headers)?; + let payjoin_proposal = self.process_v1_proposal(proposal)?; + let psbt = payjoin_proposal.psbt(); + let body = base64::encode(psbt.serialize()); + println!("Responded with Payjoin proposal {}", psbt.clone().extract_tx().txid()); + Ok(Response::new(Body::from(body))) + } + + #[cfg(not(feature = "v2"))] + fn process_v1_proposal(&self, proposal: UncheckedProposal) -> Result { let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx @@ -342,26 +504,110 @@ impl App { .assume_checked(); provisional_payjoin.substitute_output_address(receiver_substitute_address); - let payjoi_proposal = provisional_payjoin.finalize_proposal( + let payjoin_proposal = provisional_payjoin.finalize_proposal( |psbt: &Psbt| { bitcoind - .wallet_process_psbt( - &payjoin::base64::encode(psbt.serialize()), - None, - None, - Some(false), - ) + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) .map(|res| Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) .map_err(|e| Error::Server(e.into()))? }, Some(bitcoin::FeeRate::MIN), )?; - let payjoin_proposal_psbt = payjoi_proposal.psbt(); - log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + println!( + "Responded with Payjoin proposal {}", + payjoin_proposal_psbt.clone().extract_tx().txid() + ); + Ok(payjoin_proposal) + } + + #[cfg(feature = "v2")] + fn process_v2_proposal( + &self, + proposal: v2::UncheckedProposal, + ) -> Result { + let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; + + // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx + let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); + + // The network is used for checks later + let network = + bitcoind.get_blockchain_info().map_err(|e| Error::Server(e.into())).and_then( + |info| bitcoin::Network::from_str(&info.chain).map_err(|e| Error::Server(e.into())), + )?; + + // Receive Check 1: Can Broadcast + let proposal = proposal.check_can_broadcast(|tx| { + let raw_tx = bitcoin::consensus::encode::serialize_hex(&tx); + let mempool_results = + bitcoind.test_mempool_accept(&[raw_tx]).map_err(|e| Error::Server(e.into()))?; + match mempool_results.first() { + Some(result) => Ok(result.allowed), + None => Err(Error::Server( + anyhow!("No mempool results returned on broadcast check").into(), + )), + } + })?; + log::trace!("check1"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal.check_inputs_not_owned(|input| { + if let Ok(address) = bitcoin::Address::from_script(input, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })?; + log::trace!("check2"); + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts()?; + log::trace!("check3"); + + // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let payjoin = proposal.check_no_inputs_seen_before(|input| { + Ok(!self.insert_input_seen_before(*input).map_err(|e| Error::Server(e.into()))?) + })?; + log::trace!("check4"); + + let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output_script| { + if let Ok(address) = bitcoin::Address::from_script(output_script, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })?; - let payload = payjoin::base64::encode(payjoin_proposal_psbt.serialize()); - log::info!("successful response"); - Ok(Response::new(Body::from(payload))) + if !self.config.sub_only { + // Select receiver payjoin inputs. + _ = try_contributing_inputs(&mut provisional_payjoin.inner, &bitcoind) + .map_err(|e| log::warn!("Failed to contribute inputs: {}", e)); + } + + let receiver_substitute_address = bitcoind + .get_new_address(None, None) + .map_err(|e| Error::Server(e.into()))? + .assume_checked(); + provisional_payjoin.substitute_output_address(receiver_substitute_address); + + let payjoin_proposal = provisional_payjoin.finalize_proposal( + |psbt: &Psbt| { + bitcoind + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) + .map(|res| Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) + .map_err(|e| Error::Server(e.into()))? + }, + Some(bitcoin::FeeRate::MIN), + )?; + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); + Ok(payjoin_proposal) } fn insert_input_seen_before(&self, input: bitcoin::OutPoint) -> Result { @@ -408,6 +654,8 @@ pub(crate) struct AppConfig { pub bitcoind_cookie: Option, pub bitcoind_rpcuser: String, pub bitcoind_rpcpass: String, + pub ohttp_config: String, + pub ohttp_proxy: String, // receive-only pub pj_host: String, @@ -438,6 +686,16 @@ impl AppConfig { "bitcoind_rpcpass", matches.get_one::("rpcpass").map(|s| s.as_str()), )? + .set_default("ohttp_config", "")? + .set_override_option( + "ohttp_config", + matches.get_one::("ohttp_config").map(|s| s.as_str()), + )? + .set_default("ohttp_proxy", "")? + .set_override_option( + "ohttp_proxy", + matches.get_one::("ohttp_proxy").map(|s| s.as_str()), + )? // Subcommand defaults without which file serialization fails. .set_default("pj_host", "0.0.0.0:3000")? .set_default("pj_endpoint", "https://localhost:3000")? @@ -459,12 +717,13 @@ impl AppConfig { _ => unreachable!(), // If all subcommands are defined above, anything else is unreachabe!() }; let app_conf = builder.build()?; + log::debug!("App config: {:?}", app_conf); app_conf.try_deserialize().context("Failed to deserialize config") } } fn try_contributing_inputs( - payjoin: &mut ProvisionalProposal, + payjoin: &mut payjoin::receive::ProvisionalProposal, bitcoind: &bitcoincore_rpc::Client, ) -> Result<()> { use bitcoin::OutPoint; @@ -502,7 +761,7 @@ impl payjoin::receive::Headers for Headers<'_> { } } -fn serialize_psbt(psbt: &Psbt) -> String { payjoin::base64::encode(&psbt.serialize()) } +fn serialize_psbt(psbt: &Psbt) -> String { base64::encode(psbt.serialize()) } #[cfg(feature = "danger-local-https")] fn http_agent() -> Result { diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index 0c82f06f..463c7124 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -52,6 +52,17 @@ fn cli() -> ArgMatches { .takes_value(true) .help("The password for the bitcoin node")) .subcommand_required(true) + .arg(Arg::new("ohttp_config") + .long("ohttp-config") + .help("The ohttp config file")) + .arg(Arg::new("ohttp_proxy") + .long("ohttp-proxy") + .help("The ohttp proxy url")) + .arg(Arg::new("DANGER_ACCEPT_INVALID_CERTS") + .long("danger-accept-invalid-certs") + .action(clap::ArgAction::SetTrue) + .hide(true) + .help("Wicked dangerous! Vulnerable to MITM attacks! Accept invalid certs for the payjoin endpoint")) .subcommand( Command::new("send") .arg_required_else_help(true) diff --git a/payjoin-relay/Cargo.toml b/payjoin-relay/Cargo.toml index 0b4aa494..e3dbbde5 100644 --- a/payjoin-relay/Cargo.toml +++ b/payjoin-relay/Cargo.toml @@ -5,12 +5,18 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +danger-local-https = ["hyper-rustls", "rcgen", "rustls"] + [dependencies] hyper = { version = "0.14", features = ["full"] } +hyper-rustls = { version = "0.24", optional = true } anyhow = "1.0.71" payjoin = { path = "../payjoin", features = ["base64", "v2"] } ohttp = "0.4.0" bhttp = { version = "0.4.0", features = ["http"] } +rcgen = { version = "0.11", optional = true } +rustls = { version = "0.21", optional = true } sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] } tokio = { version = "1.12.0", features = ["full"] } tracing = "0.1.37" diff --git a/payjoin-relay/src/main.rs b/payjoin-relay/src/main.rs index 4f506bdf..aac5663b 100644 --- a/payjoin-relay/src/main.rs +++ b/payjoin-relay/src/main.rs @@ -4,6 +4,8 @@ use std::str::FromStr; use std::sync::Arc; use anyhow::Result; +use hyper::server::conn::AddrIncoming; +use hyper::server::Builder; use hyper::service::{make_service_fn, service_fn}; use hyper::{Body, Method, Request, Response, StatusCode, Uri}; use payjoin::{base64, bitcoin}; @@ -48,7 +50,7 @@ async fn main() -> Result<(), Box> { // Parse the bind address using the provided port let bind_addr_str = format!("0.0.0.0:{}", relay_port); let bind_addr: SocketAddr = bind_addr_str.parse()?; - let server = hyper::Server::bind(&bind_addr).serve(make_svc); + let server = init_server(&bind_addr)?.serve(make_svc); info!("Serverless payjoin relay awaiting HTTP connection at {}", bind_addr_str); Ok(server.await?) } @@ -82,6 +84,29 @@ fn init_ohttp() -> Result { Ok(ohttp::Server::new(server_config)?) } +#[cfg(not(feature = "danger-local-https"))] +fn init_server(bind_addr: &SocketAddr) -> Result> { + Ok(hyper::Server::bind(&bind_addr)) +} + +#[cfg(feature = "danger-local-https")] +fn init_server(bind_addr: &SocketAddr) -> Result> { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; + use hyper::Server; + use rustls::{Certificate, PrivateKey}; + + let key = PrivateKey(cert.serialize_private_key_der()); + let certs = vec![Certificate(cert.serialize_der()?)]; + let incoming = AddrIncoming::bind(bind_addr)?; + let acceptor = hyper_rustls::TlsAcceptor::builder() + .with_single_cert(certs, key) + .map_err(|e| anyhow::anyhow!("TLS error: {}", e))? + .with_all_versions_alpn() + .with_incoming(incoming); + let server = Server::builder(acceptor); + Ok(server) +} + async fn handle_ohttp_gateway( req: Request, pool: DbPool, diff --git a/payjoin-relay/tests/e2e.rs b/payjoin-relay/tests/e2e.rs new file mode 100644 index 00000000..aa3348a1 --- /dev/null +++ b/payjoin-relay/tests/e2e.rs @@ -0,0 +1,194 @@ +use std::env; +use std::process::Stdio; + +use bitcoind::bitcoincore_rpc::core_rpc_json::AddressType; +use bitcoind::bitcoincore_rpc::RpcApi; +use log::{log_enabled, Level}; +use payjoin::bitcoin::Amount; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::clients::Cli; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; + +const PJ_RELAY_URL: &str = "https://localhost:8088"; +const OH_RELAY_URL: &str = "https://localhost:8088"; +const RECEIVE_SATS: &str = "54321"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[cfg(feature = "danger-local-https")] +async fn e2e() { + // Compile payjoin-cli with default features + let mut compile_v1 = compile_payjoin_cli(false).await; + // Compile payjoin-cli with v2 features + let mut compile_v2 = compile_payjoin_cli(true).await; + + std::env::set_var("RUST_LOG", "debug"); + std::env::set_var("PJ_RELAY_PORT", "8088"); + std::env::set_var("PJ_RELAY_TIMEOUT_SECS", "1"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let node = docker.run(Postgres::default()); + std::env::set_var("PJ_DB_HOST", format!("localhost:{}", node.get_host_port_ipv4(5432))); + + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| bitcoind::downloaded_exe_path().ok()) + .expect("version feature or env BITCOIND_EXE is required for tests"); + let mut conf = bitcoind::Conf::default(); + conf.view_stdout = log_enabled!(Level::Debug); + let mut relay = Command::new(env!("CARGO_BIN_EXE_payjoin-relay")) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-relay"); + log::debug!("Relay started"); + let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf).unwrap(); + let receiver = bitcoind.create_wallet("receiver").unwrap(); + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); + let sender = bitcoind.create_wallet("sender").unwrap(); + let sender_address = + sender.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); + bitcoind.client.generate_to_address(1, &receiver_address).unwrap(); + bitcoind.client.generate_to_address(101, &sender_address).unwrap(); + + assert_eq!( + Amount::from_btc(50.0).unwrap(), + receiver.get_balances().unwrap().mine.trusted, + "receiver doesn't own bitcoin" + ); + + assert_eq!( + Amount::from_btc(50.0).unwrap(), + sender.get_balances().unwrap().mine.trusted, + "sender doesn't own bitcoin" + ); + + let http = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("Failed to build reqwest http client"); + + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + let response = http + .get(format!("{}/ohttp-config", PJ_RELAY_URL)) + .send() + .await + .expect("Failed to send request"); + let ohttp_config = response.text().await.expect("Failed to read response"); + log::debug!("Got ohttp-config {}", &ohttp_config); + + let receiver_rpchost = format!("{}/wallet/receiver", bitcoind.params.rpc_socket); + let sender_rpchost = format!("{}/wallet/sender", bitcoind.params.rpc_socket); + + let cookie_file = &bitcoind.params.cookie_file; + + // Paths to the compiled binaries + let v1_status = compile_v1.wait().await.unwrap(); + let v2_status = compile_v2.wait().await.unwrap(); + assert!(v1_status.success(), "Process did not exit successfully"); + assert!(v2_status.success(), "Process did not exit successfully"); + + let v2_receiver = "target/v2/debug/payjoin"; + let v1_sender = "target/v1/debug/payjoin"; + + let mut cli_receiver = Command::new(v2_receiver) + .arg("--rpchost") + .arg(&receiver_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--ohttp-config") + .arg(&ohttp_config) + .arg("--ohttp-proxy") + .arg(OH_RELAY_URL) + .arg("--danger-accept-invalid-certs") + .arg("receive") + .arg(RECEIVE_SATS) + .arg("--endpoint") + .arg(PJ_RELAY_URL) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + + let stdout = cli_receiver.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let mut stdout = tokio::io::stdout(); + + let mut bip21 = String::new(); + + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + // Write to stdout regardless + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + // Check if it's the line we're interested in + if line.starts_with("BITCOIN") { + bip21 = line; + break; + } + } + log::debug!("Got bip21 {}", &bip21); + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + // Continue to write to stdout + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + if line.contains("Transaction sent") { + log::debug!("HOLY MOLY BATMAN! Transaction sent!") + } + } + }); + + let mut cli_sender = Command::new(v1_sender) + .arg("--rpchost") + .arg(&sender_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--ohttp-config") + .arg(&ohttp_config) + .arg("--ohttp-proxy") + .arg(OH_RELAY_URL) + .arg("--danger-accept-invalid-certs") + .arg("send") + .arg(&bip21) + .arg("--fee_rate") + .arg("1") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + + // delay 10 seconds + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + relay.kill().await.expect("Failed to kill payjoin-relay"); + cli_receiver.kill().await.expect("Failed to kill payjoin-cli"); + cli_sender.kill().await.expect("Failed to kill payjoin-cli"); +} + +async fn compile_payjoin_cli(feature_v2: bool) -> Child { + let target_dir = if feature_v2 { "target/v2" } else { "target/v1" }; + + env::set_var("CARGO_TARGET_DIR", target_dir); + + let mut command = Command::new("cargo"); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ + "build", + "--package", + "payjoin-cli", + ]); + + if feature_v2 { + command.args(["--features", "v2"]); + } + command.spawn().unwrap() +} diff --git a/payjoin-relay/tests/integration.rs b/payjoin-relay/tests/integration.rs index 3f559b6b..0ddc54ed 100644 --- a/payjoin-relay/tests/integration.rs +++ b/payjoin-relay/tests/integration.rs @@ -21,6 +21,7 @@ mod integration { const OH_RELAY_URL: &str = "https://localhost:8088"; #[tokio::test] + #[cfg(feature = "danger-local-https")] async fn v2_to_v2() { std::env::set_var("RUST_LOG", "debug"); std::env::set_var("PJ_RELAY_PORT", "8088");