diff --git a/Cargo.lock b/Cargo.lock index 48411c7a..898a6453 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bip21" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9532c632b068e45a478f5e309126b6e2ec1dbf0bbd327b73836f33d9a43ede" +dependencies = [ + "bitcoin", + "percent-encoding-rfc3986", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -487,11 +497,14 @@ dependencies = [ "electrum-client", "futures", "hex", + "hyper", + "hyper-rustls", "lazy_static", "miniscript", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", + "payjoin", "prost 0.12.3", "prost-wkt-types", "protobuf-src", @@ -1494,7 +1507,9 @@ dependencies = [ "futures-util", "http", "hyper", + "log", "rustls 0.21.9", + "rustls-native-certs", "tokio", "tokio-rustls 0.24.1", ] @@ -1972,6 +1987,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "opentelemetry" version = "0.21.0" @@ -2134,6 +2155,20 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "payjoin" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7b659f9e4ff06192df5d4504ea7ae866a1680eb2c87e4dca521fb14753eb0e7" +dependencies = [ + "bip21", + "bitcoin", + "log", + "rand", + "serde_json", + "url", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2149,6 +2184,12 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "percent-encoding-rfc3986" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" + [[package]] name = "petgraph" version = "0.5.1" @@ -2920,6 +2961,18 @@ dependencies = [ "sct 0.7.1", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2961,6 +3014,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3014,6 +3076,29 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.194" diff --git a/Cargo.toml b/Cargo.toml index 968660d0..798c58f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,12 +39,15 @@ futures = "0.3.30" url = "2.5.0" rand = "0.8.5" bdk = "0.29.0" +payjoin = { version = "0.13.0", features = ["base64", "receive"] } lazy_static = "1.4.0" opentelemetry = { version = "0.21.0" } opentelemetry_sdk = { version = "0.21.0", features = ["rt-tokio"] } serde_with = "3.4.0" electrum-client = "0.18.0" reqwest = { version = "0.11.23", default-features = false, features = ["json", "rustls-tls"] } +hyper = { version = "0.14", features = ["full"] } +hyper-rustls = { version = "0.24", optional = true } tonic_lnd = { version = "0.5.0", features = ["tracing"] } async-trait = "0.1" base64 = "0.21.5" diff --git a/src/api/server/convert.rs b/src/api/server/convert.rs index e89fbb99..053d23c7 100644 --- a/src/api/server/convert.rs +++ b/src/api/server/convert.rs @@ -233,7 +233,7 @@ impl From for proto::PayoutQueue { proto::payout_queue_config::Trigger::IntervalSecs(seconds.as_secs() as u32) } PayoutQueueTrigger::Manual => proto::payout_queue_config::Trigger::Manual(true), - PayoutQueueTrigger::Payjoin => proto::payout_queue_config::Trigger::Payjoin(true) + PayoutQueueTrigger::Payjoin => proto::payout_queue_config::Trigger::Payjoin(true), }; let tx_priority: proto::TxPriority = payout_queue.config.tx_priority.into(); let config = Some(proto::PayoutQueueConfig { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6c8504f4..3730f25c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1047,6 +1047,14 @@ async fn run_cmd( .context("Api server error") }); })); + let payjoin_send = send.clone(); + handles.push(tokio::spawn(async move { + let _ = payjoin_send.try_send( + super::payjoin::run(pool, app) + .await + .context("Payjoin server error") + ); + })); let reason = receive.recv().await.expect("Didn't receive msg"); for handle in handles { diff --git a/src/lib.rs b/src/lib.rs index e7c95bf9..f41d51d3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod fees; mod job; pub mod ledger; mod outbox; +mod payjoin; pub mod payout; pub mod payout_queue; pub mod primitives; diff --git a/src/payjoin/app.rs b/src/payjoin/app.rs new file mode 100644 index 00000000..eaf2f60b --- /dev/null +++ b/src/payjoin/app.rs @@ -0,0 +1,149 @@ +use std::{str::FromStr, collections::HashMap}; + +use anyhow::{anyhow, Result}; +use payjoin::{receive::{PayjoinProposal, UncheckedProposal, ProvisionalProposal}, Error}; +use tracing::instrument; + +use super::error::*; +use crate::primitives::bitcoin; + +const BOOTSTRAP_KEY_NAME: &str = "payjoin_bootstrap_key"; + +pub struct PayjoinApp { + // config: bitcoind ledger, wallet, pj_host, pj_endpoint + // ledger: Ledger, + // network: bitcoin::Network, + pool: sqlx::PgPool, +} + +impl PayjoinApp { + pub fn new(pool: sqlx::PgPool) -> Self { + Self { + pool, + } + } + + #[instrument(name = "payjoin_app.process_proposal", skip(self), err)] + fn process_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 + 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_broadcast_suitability(None, |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(), + )), + } + })?; + tracing::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::BdkAddress::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) + } + })?; + tracing::trace!("check2"); + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts()?; + tracing::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| { + // TODO implement input_seen_before database check + // Ok(!self.insert_input_seen_before(*input).map_err(|e| Error::Server(e.into()))?) + Ok(false) + })?; + tracing::trace!("check4"); + + let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output_script| { + if let Ok(address) = bitcoin::BdkAddress::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) + } + })?; + + if !self.config.sub_only { + // Select receiver payjoin inputs. + _ = try_contributing_inputs(&mut provisional_payjoin, &bitcoind) + .map_err(|e| tracing::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: &bitcoin::psbt::Psbt| { + bitcoind + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) + .map(|res| bitcoin::psbt::Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) + .map_err(|e| Error::Server(e.into()))? + }, + None, // TODO set to bitcoin::FeeRate::MIN or similar + )?; + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + println!( + "Responded with Payjoin proposal {}", + payjoin_proposal_psbt.clone().extract_tx().txid() + ); + Ok(payjoin_proposal) + } + + + fn try_contributing_inputs( + payjoin: &mut ProvisionalProposal, + bitcoind: &bitcoincore_rpc::Client, + ) -> Result<()> { + use bitcoin::OutPoint; + + let available_inputs = bitcoind + .list_unspent(None, None, None, None, None) + .context("Failed to list unspent from bitcoind")?; + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .context("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.")?; + tracing::debug!("selected utxo: {:#?}", selected_utxo); + + // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount.to_sat(), + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + let outpoint_to_contribute = + bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; + payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); + Ok(()) + } +} \ No newline at end of file diff --git a/src/payjoin/config.rs b/src/payjoin/config.rs new file mode 100644 index 00000000..2333ae5f --- /dev/null +++ b/src/payjoin/config.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PayjoinConfig { + #[serde(default = "default_port")] + pub listen_port: u16, +} +impl Default for PayjoinConfig { + fn default() -> Self { + Self { + listen_port: default_port(), + } + } +} + +fn default_port() -> u16 { + 8088 +} diff --git a/src/payjoin/error.rs b/src/payjoin/error.rs new file mode 100644 index 00000000..39c4118b --- /dev/null +++ b/src/payjoin/error.rs @@ -0,0 +1,8 @@ +use thiserror::Error; + +#[allow(clippy::large_enum_variant)] +#[derive(Error, Debug)] +pub enum PayjoinError { + #[error("PayjoinError - Error")] + Error, +} diff --git a/src/payjoin/mod.rs b/src/payjoin/mod.rs new file mode 100644 index 00000000..b2be0036 --- /dev/null +++ b/src/payjoin/mod.rs @@ -0,0 +1,68 @@ +mod app; +mod config; +pub mod error; +mod server; + +pub use app::*; +pub use config::*; +pub use error::*; +pub use server::*; + +// pub async fn run_dev( +// pool: sqlx::PgPool, +// config: ApiConfig, +// app_cfg: AppConfig, +// xpub: Option<(String, String)>, +// derivation_path: Option, +// ) -> Result<(), ApplicationError> { +// use crate::{ +// dev_constants, +// payout_queue::*, +// profile::Profiles, +// xpub::{BitcoindSignerConfig, SignerConfig}, +// }; + +// let app = App::run(pool.clone(), app_cfg).await?; +// if let Some((xpub, signer_endpoint)) = xpub { +// println!("Creating dev entities"); +// let profile = Profiles::new(&pool) +// .find_by_key(dev_constants::BRIA_DEV_KEY) +// .await?; +// let (_, xpubs) = app +// .create_wpkh_wallet( +// &profile, +// dev_constants::DEV_WALLET_NAME.to_string(), +// xpub, +// derivation_path, +// ) +// .await?; +// app.set_signer_config( +// &profile, +// xpubs[0].to_string(), +// SignerConfig::Bitcoind(BitcoindSignerConfig { +// endpoint: signer_endpoint, +// rpc_user: dev_constants::DEFAULT_BITCOIND_RPC_USER.to_string(), +// rpc_password: dev_constants::DEFAULT_BITCOIND_RPC_PASSWORD.to_string(), +// }), +// ) +// .await?; +// app.create_payout_queue( +// &profile, +// dev_constants::DEV_QUEUE_NAME.to_string(), +// None, +// Some(PayoutQueueConfig { +// trigger: PayoutQueueTrigger::Payjoin, +// ..PayoutQueueConfig::default() +// }), +// ) +// .await?; +// } +// server::start(config, app).await?; +// Ok(()) +// } + +pub async fn run(pool: sqlx::PgPool, config: PayjoinConfig) -> Result<(), PayjoinError> { + let app = PayjoinApp::new(pool); + server::start(config, app).await?; + Ok(()) +} diff --git a/src/payjoin/server/mod.rs b/src/payjoin/server/mod.rs new file mode 100644 index 00000000..d47ad8ef --- /dev/null +++ b/src/payjoin/server/mod.rs @@ -0,0 +1,85 @@ + +use hyper::{Server, service::{make_service_fn, service_fn}, Response, Request, Method, Body, StatusCode}; +use payjoin::Error; +use tracing::instrument; + +use super::{config::*, error::*, PayjoinApp}; + +pub struct Payjoin { + app: PayjoinApp, +} + +impl Payjoin { + #[instrument(skip_all, err)] + async fn handle_web_request(self, req: Request) -> Result, Error> { + let mut response = match (req.method(), req.uri().path()) { + (&Method::POST, _) => self + .handle_payjoin_post(req) + .await + .map_err(|e| match e { + Error::BadRequest(e) => { + Response::builder().status(400).body(Body::from(e.to_string())).unwrap() + } + e => { + Response::builder().status(500).body(Body::from(e.to_string())).unwrap() + } + }) + .unwrap_or_else(|err_resp| err_resp), + _ => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Not found")) + .unwrap(), + }; + response + .headers_mut() + .insert("Access-Control-Allow-Origin", hyper::header::HeaderValue::from_static("*")); + Ok(response) + } + + #[instrument(skip_all, err)] + async fn handle_payjoin_post(self, req: Request) -> Result, Error> { + let (parts, body) = req.into_parts(); + let headers = Headers(&parts.headers); + let query_string = parts.uri.query().unwrap_or(""); + let body = std::io::Cursor::new( + hyper::body::to_bytes(body).await.map_err(|e| Error::Server(e.into()))?.to_vec(), + ); + let proposal = + payjoin::receive::UncheckedProposal::from_request(body, query_string, headers)?; + + let payjoin_proposal = self.app.process_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))) + } +} + +pub(crate) async fn start( + server_config: PayjoinConfig, + app: PayjoinApp, +) -> Result<(), PayjoinError> { + let payjoin = Payjoin { app }; + println!( + "Starting payjoin server on port {}", + server_config.listen_port + ); + + let server = Server::bind(([0, 0, 0, 0], server_config.listen_port).into()); + let make_svc = make_service_fn(|_| { + async move { + let handler = move |req| payjoin.handle_web_request(req); + Ok::<_, hyper::Error>(service_fn(handler)) + } + }); + server.serve(make_svc).await?; + Ok(()) +} + +struct Headers<'a>(&'a hyper::HeaderMap); + +impl payjoin::receive::Headers for Headers<'_> { + fn get_header(&self, key: &str) -> Option<&str> { + self.0.get(key).map(|v| v.to_str()).transpose().ok().flatten() + } +} \ No newline at end of file diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 3a32252b..807af863 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -80,6 +80,7 @@ impl std::ops::Deref for XPubId { pub mod bitcoin { pub use bdk::{ bitcoin::{ + Amount, address::{Error as AddressError, NetworkChecked, NetworkUnchecked}, bip32::{self, DerivationPath, ExtendedPubKey, Fingerprint}, blockdata::{