diff --git a/Cargo.toml b/Cargo.toml index 631ed9299..76e8a72fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,9 @@ cdk-sqlite = { version = "0.2", path = "./crates/cdk-sqlite", default-features = cdk-redb = { version = "0.2", path = "./crates/cdk-redb", default-features = false } cdk-cln = { version = "0.1", path = "./crates/cdk-cln", default-features = false } cdk-axum = { version = "0.1", path = "./crates/cdk-axum", default-features = false } +cdk-bdk = { version = "0.1", path = "./crates/cdk-bdk", default-features = false } cdk-fake-wallet = { version = "0.1", path = "./crates/cdk-fake-wallet", default-features = false } +payjoin = { version = "0.19.0", features = ["receive", "v2", "io"] } tokio = { version = "1", default-features = false } thiserror = "1" tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } @@ -44,6 +46,12 @@ web-sys = { version = "0.3.69", default-features = false, features = ["console" uuid = { version = "1", features = ["v4"] } lightning-invoice = { version = "0.31", features = ["serde"] } home = "0.5.9" +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "rustls-tls", + "rustls-tls-native-roots", + "socks", +] } [profile] diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index 72899905e..6524db263 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -10,7 +10,7 @@ use cdk::error::{Error, ErrorResponse}; use cdk::nuts::nut05::MeltBolt11Response; use cdk::nuts::nut17::{ MintBtcOnchainRequest, MintBtcOnchainResponse, MintQuoteBtcOnchainRequest, - MintQuoteBtcOnchainResponse, + MintQuoteBtcOnchainResponse, PayjoinInfo, }; use cdk::nuts::nut18::{MeltQuoteBtcOnchainRequest, MeltQuoteBtcOnchainResponse}; use cdk::nuts::{ @@ -106,11 +106,12 @@ pub async fn get_mint_onchain_quote( State(state): State, Json(payload): Json, ) -> Result, Response> { + tracing::debug!("Mint quote unit: {}", payload.unit); let onchain = state .onchain - .get(&LnKey::new(payload.unit, PaymentMethod::Bolt11)) + .get(&LnKey::new(payload.unit, PaymentMethod::BtcOnChain)) .ok_or({ - tracing::info!("Bolt11 mint request for unsupported unit"); + tracing::info!("Onchain mint request for unsupported unit"); into_response(Error::UnsupportedUnit) })?; @@ -118,7 +119,7 @@ pub async fn get_mint_onchain_quote( let quote_expiry = unix_time() + state.quote_ttl; let address = onchain.new_address().await.map_err(|err| { - tracing::error!("Could not create invoice: {}", err); + tracing::error!("Could not get onchain address: {}", err); into_response(Error::InvalidPaymentRequest) })?; @@ -126,11 +127,11 @@ pub async fn get_mint_onchain_quote( .mint .new_mint_quote( state.mint_url.into(), - address.clone(), + address.address.clone(), payload.unit, payload.amount, quote_expiry, - address, + address.address, ) .await .map_err(|err| { @@ -138,7 +139,28 @@ pub async fn get_mint_onchain_quote( into_response(err) })?; - Ok(Json(quote.into())) + let settings = onchain.get_settings(); + + let payjoin = match settings.payjoin_settings.receive_enabled { + true => match (settings.payjoin_settings.ohttp_relay, address.payjoin_url) { + (Some(ohttp_relay), Some(payjoin_directory)) => Some(PayjoinInfo { + origin: payjoin_directory, + ohttp_relay: Some(ohttp_relay), + pjos: false, + }), + _ => None, + }, + false => None, + }; + + let mint_quote_response = MintQuoteBtcOnchainResponse { + quote: quote.id, + address: quote.request, + state: quote.state, + payjoin, + }; + + Ok(Json(mint_quote_response)) } pub async fn get_check_mint_bolt11_quote( @@ -171,14 +193,22 @@ pub async fn get_check_mint_onchain_quote( let address = quote.request.clone(); + tracing::debug!("{:?}", state.onchain.keys()); + let onchain = state .onchain - .get(&LnKey::new(quote.unit, PaymentMethod::BtcOnChain)) - .ok_or({ - tracing::info!("Bolt11 mint request for unsupported unit"); + .get(&LnKey::new(CurrencyUnit::Sat, PaymentMethod::BtcOnChain)); - into_response(Error::UnsupportedUnit) - })?; + let onchain = match onchain { + Some(onchain) => onchain, + None => { + tracing::info!("Checking quote for unsupported unit"); + + return Err(into_response(Error::UnsupportedUnit)); + } + }; + + tracing::info!("Checking paid status for {}", quote_id); let AddressPaidResponse { amount, @@ -194,10 +224,30 @@ pub async fn get_check_mint_onchain_quote( state.mint.update_mint_quote(quote).await.unwrap(); } + let settings = onchain.get_settings(); + + let payjoin = match settings.payjoin_settings.receive_enabled { + true => { + match ( + settings.payjoin_settings.ohttp_relay, + settings.payjoin_settings.payjoin_directory, + ) { + (Some(ohttp_relay), Some(payjoin_directory)) => Some(PayjoinInfo { + origin: payjoin_directory, + ohttp_relay: Some(ohttp_relay), + pjos: false, + }), + _ => None, + } + } + false => None, + }; + let res = MintQuoteBtcOnchainResponse { quote: quote.id, address: quote.request, - state: quote.state.clone(), + state: quote.state, + payjoin, }; Ok(Json(res)) diff --git a/crates/cdk-bdk/Cargo.toml b/crates/cdk-bdk/Cargo.toml index f45efb8f0..98f404b37 100644 --- a/crates/cdk-bdk/Cargo.toml +++ b/crates/cdk-bdk/Cargo.toml @@ -12,12 +12,13 @@ description = "BDK on chain wallet for mint" [dependencies] anyhow.workspace = true async-trait.workspace = true -bitcoin = "0.32.0" -bdk_chain = "0.16.0" -bdk_wallet = { version = "1.0.0-alpha.13", default-features = false, features = ["keys-bip39"] } +bdk_chain = {version = "0.16.0", features = ["std"]} +bdk_wallet = { version = "1.0.0-alpha.13", default-features = false, features = ["keys-bip39", "std"] } bdk_esplora = { version = "0.15.0", default-features = false, features = ["std", "async-https"] } bdk_file_store = "0.13.0" cdk = { workspace = true, default-features = false, features = ["mint"] } +payjoin.workspace = true +reqwest.workspace = true futures.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/cdk-bdk/src/error.rs b/crates/cdk-bdk/src/error.rs index ba6d7261d..100fba8d7 100644 --- a/crates/cdk-bdk/src/error.rs +++ b/crates/cdk-bdk/src/error.rs @@ -13,6 +13,8 @@ pub enum Error { UnknownInvoice, #[error(transparent)] Anyhow(#[from] anyhow::Error), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), /// Esplora client error #[error(transparent)] EsploraClient(#[from] bdk_esplora::esplora_client::Error), diff --git a/crates/cdk-bdk/src/lib.rs b/crates/cdk-bdk/src/lib.rs index 80174d723..2056955db 100644 --- a/crates/cdk-bdk/src/lib.rs +++ b/crates/cdk-bdk/src/lib.rs @@ -1,40 +1,55 @@ //! CDK lightning backend for CLN -use std::path::PathBuf; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::io::Write; +use std::path::Path; use std::str::FromStr; use std::sync::Arc; -use anyhow::{anyhow, Error}; +use anyhow::{anyhow, Error, Result}; use async_trait::async_trait; -use bdk_chain::{miniscript, ConfirmationTime}; -use bdk_esplora::esplora_client; +use bdk_chain::{miniscript, BlockId, ConfirmationTime}; +use bdk_esplora::{esplora_client, EsploraAsyncExt}; use bdk_file_store::Store; use bdk_wallet::bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; -use bdk_wallet::bitcoin::{Address, FeeRate, Network}; +use bdk_wallet::bitcoin::{Address, FeeRate, Network, Script}; use bdk_wallet::keys::bip39::Mnemonic; use bdk_wallet::keys::{DerivableKey, ExtendedKey}; use bdk_wallet::template::DescriptorTemplateOut; use bdk_wallet::{KeychainKind, SignOptions, Wallet}; use cdk::amount::Amount; -use cdk::cdk_onchain::{self, AddressPaidResponse, MintOnChain, Settings}; +use cdk::bitcoin::bech32::ToBase32; +use cdk::cdk_onchain::{ + self, AddressPaidResponse, MintOnChain, NewAddressResponse, PayjoinSettings, Settings, +}; use cdk::mint; use cdk::nuts::CurrencyUnit; use cdk::util::unix_time; +use payjoin::receive::v2::{PayjoinProposal, ProvisionalProposal, UncheckedProposal}; +use payjoin::Url; use tokio::sync::Mutex; pub mod error; const DB_MAGIC: &str = "bdk_wallet_electrum_example"; +const STOP_GAP: usize = 50; +const PARALLEL_REQUESTS: usize = 5; +#[derive(Clone)] pub struct BdkWallet { wallet: Arc>, client: esplora_client::AsyncClient, + db: Arc>>, min_melt_amount: u64, max_melt_amount: u64, min_mint_amount: u64, max_mint_amount: u64, mint_enabled: bool, melt_enabled: bool, + payjoin_settings: PayjoinSettings, + sender: tokio::sync::mpsc::Sender, + receiver: Arc>>, + seen_inputs: Arc>>, } impl BdkWallet { @@ -43,12 +58,13 @@ impl BdkWallet { max_melt_amount: u64, min_mint_amount: u64, max_mint_amount: u64, - // REVIEW: I think it maybe best if we force a Mnemonic here as it will hole onchain funds. + // REVIEW: I think it maybe best if we force a Mnemonic here as it will hold onchain funds. // But maybe should be a byte seed like we do for mint and wallet? mnemonic: Mnemonic, - work_dir: &PathBuf, - network: Network, + work_dir: &Path, + payjoin_settings: PayjoinSettings, ) -> Result { + let network = Network::Signet; let db_path = work_dir.join("bdk-mint"); let mut db = Store::::open_or_create_new( DB_MAGIC.as_bytes(), @@ -68,7 +84,7 @@ impl BdkWallet { let (internal_descriptor, external_descriptor) = get_wpkh_descriptors_for_extended_key(xprv, network, 0)?; - let wallet = Wallet::new_or_load( + let mut wallet = Wallet::new_or_load( internal_descriptor, external_descriptor, changeset, @@ -76,11 +92,63 @@ impl BdkWallet { ) .map_err(|_| anyhow!("Could not create cdk wallet"))?; - let client = - esplora_client::Builder::new("http://signet.bitcoindevkit.net").build_async()?; + let client = esplora_client::Builder::new("https://mutinynet.com/api").build_async()?; + + fn generate_inspect( + kind: KeychainKind, + ) -> impl FnMut(u32, &Script) + Send + Sync + 'static { + let mut once = Some(()); + let mut stdout = std::io::stdout(); + move |spk_i, _| { + match once.take() { + Some(_) => print!("\nScanning keychain [{:?}]", kind), + None => print!(" {:<3}", spk_i), + }; + stdout.flush().expect("must flush"); + } + } + + let request = wallet + .start_full_scan() + .inspect_spks_for_all_keychains({ + let mut once = BTreeSet::::new(); + move |keychain, spk_i, _| { + match once.insert(keychain) { + true => print!("\nScanning keychain [{:?}]", keychain), + false => print!(" {:<3}", spk_i), + } + std::io::stdout().flush().expect("must flush") + } + }) + .inspect_spks_for_keychain( + KeychainKind::External, + generate_inspect(KeychainKind::External), + ) + .inspect_spks_for_keychain( + KeychainKind::Internal, + generate_inspect(KeychainKind::Internal), + ); + + tracing::debug!("Starting wallet full scan"); + + let mut update = client + .full_scan(request, STOP_GAP, PARALLEL_REQUESTS) + .await?; + let now = std::time::UNIX_EPOCH.elapsed().unwrap().as_secs(); + let _ = update.graph_update.update_last_seen_unconfirmed(now); + + wallet.apply_update(update)?; + if let Some(changeset) = wallet.take_staged() { + db.append_changeset(&changeset)?; + } + + tracing::debug!("Completed wallet scan"); + + let (sender, receiver) = tokio::sync::mpsc::channel(8); Ok(Self { wallet: Arc::new(Mutex::new(wallet)), + db: Arc::new(Mutex::new(db)), client, min_mint_amount, max_mint_amount, @@ -88,8 +156,338 @@ impl BdkWallet { max_melt_amount, mint_enabled: true, melt_enabled: true, + payjoin_settings, + sender, + receiver: Arc::new(Mutex::new(receiver)), + seen_inputs: Arc::new(Mutex::new(HashSet::new())), }) } + + async fn update_chain_tip(&self) -> Result<(), Error> { + let mut wallet = self.wallet.lock().await; + let latest_checkpoint = wallet.latest_checkpoint(); + let latest_checkpoint_height = latest_checkpoint.height(); + + tracing::info!("Current wallet known height: {}", latest_checkpoint_height); + + let mut fetched_blocks: Vec = vec![]; + + let mut last_fetched_height = None; + + while last_fetched_height.is_none() + || last_fetched_height.expect("Checked for none") > latest_checkpoint_height + { + let blocks = self.client.get_blocks(last_fetched_height).await?; + + for block in blocks { + match last_fetched_height { + Some(height) if block.time.height < height => { + last_fetched_height = Some(block.time.height); + } + None => { + tracing::info!("Current block tip: {}", block.time.height); + last_fetched_height = Some(block.time.height); + } + _ => {} + } + let block_id = BlockId { + height: block.time.height, + hash: block.id, + }; + + match block.time.height > latest_checkpoint_height { + true => fetched_blocks.push(block_id), + false => break, + } + } + } + + fetched_blocks.reverse(); + + for block_id in fetched_blocks { + tracing::trace!("Inserting wallet checkpoint: {}", block_id.height); + wallet.insert_checkpoint(block_id)?; + } + + if let Some(changeset) = wallet.take_staged() { + let mut db = self.db.lock().await; + db.append_changeset(&changeset)?; + } + + Ok(()) + } +} + +// TODO: Making this a payjoin trait +impl BdkWallet { + async fn start_payjoin(&self, address: &str) -> Result { + let ohttp_relay = self + .payjoin_settings + .ohttp_relay + .clone() + .ok_or(anyhow!("ohttp relay required"))?; + let payjoin_directory = self + .payjoin_settings + .payjoin_directory + .clone() + .ok_or(anyhow!("payjoin directory required"))?; + + let ohttp_relay: Url = ohttp_relay.parse()?; + let payjoin_directory: Url = payjoin_directory.parse()?; + + // Fetch keys using HTTP CONNECT method + let ohttp_keys = + payjoin::io::fetch_ohttp_keys(ohttp_relay.clone(), payjoin_directory.clone()).await?; + + let mut session = payjoin::receive::v2::SessionInitializer::new( + payjoin::bitcoin::Address::from_str(address)?.assume_checked(), + payjoin_directory, + ohttp_keys, + ohttp_relay, + Some(std::time::Duration::from_secs(600)), + ); + let (req, ctx) = session.extract_req().unwrap(); + let http = reqwest::Client::new(); + + let res = http + .post(req.url) + .body(req.body) + .header("Content-Type", payjoin::V2_REQ_CONTENT_TYPE) + .send() + .await + .unwrap(); + let mut session = session + .process_res(res.bytes().await?.to_vec().as_slice(), ctx) + .unwrap(); + + let uri = session + .pj_uri_builder() + .amount(payjoin::bitcoin::Amount::from_sat(88888)) + .build(); + + tracing::info!("PJ url: {}", session.pj_url()); + println!("Payjoin URI: {}", uri); + tracing::debug!("{}", uri.to_string()); + let pj_url = session.pj_url().to_string(); + + let sender = self.sender.clone(); + tokio::spawn(async move { + let proposal = loop { + tracing::debug!("Polling for proposal"); + let (req, ctx) = match session.extract_req() { + Ok((res, tx)) => (res, tx), + Err(err) => { + tracing::info!("Error extracting session: {}", err); + break None; + } + }; + + let res = match http + .post(req.url) + .body(req.body) + .header("Content-Type", payjoin::V2_REQ_CONTENT_TYPE) + .send() + .await + { + Ok(res) => res, + Err(err) => { + tracing::error!("Error making payjoin polling request: {}", err); + + continue; + } + }; + + match session.process_res(res.bytes().await.unwrap().to_vec().as_slice(), ctx) { + Ok(Some(proposal)) => { + break Some(proposal); + } + Ok(None) => { + continue; + } + Err(err) => { + tracing::error!("Error polling for payjoin proposal: {}", err); + continue; + } + } + }; + + if let Some(proposal) = proposal { + tracing::debug!("Received Proposal"); + if let Err(err) = sender.send(proposal).await { + tracing::error!("Could not send proposal on channel: {}", err); + } + } + }); + + Ok(pj_url) + } + + pub async fn verify_proposal( + wallet: Arc>, + proposal: UncheckedProposal, + seen_inputs: HashSet, + ) -> Result { + let wallet = wallet.lock().await; + proposal + // TODO: Check this can be broadcast + .check_broadcast_suitability(None, |_tx| Ok(true)) + .map_err(|_| anyhow!("TX cannot be broadcast"))? + .check_inputs_not_owned(|input| { + let bytes = input.to_bytes(); + let script = Script::from_bytes(&bytes); + Ok(wallet.is_mine(script)) + }) + .map_err(|_| anyhow!("Receiver should not own any of the inputs"))? + .check_no_mixed_input_scripts() + .expect("No mixed input scripts") + .check_no_inputs_seen_before(|outpoint| match seen_inputs.contains(outpoint) { + true => Ok(true), + false => Ok(false), + }) + .expect("No inputs seen before") + .identify_receiver_outputs(|output_script| { + let bytes = output_script.to_bytes(); + let script = Script::from_bytes(&bytes); + Ok(wallet.is_mine(script)) + }) + .map_err(|_| anyhow!("Receiver outputs")) + } + + pub async fn wait_handle_proposal(&self) -> Result<(), Error> { + let mut receiver = self.receiver.lock().await; + tokio::select! { + Some(proposal) = receiver.recv() => { + match self.handle_proposal(proposal).await { + Ok(()) => { + tracing::info!("Sent payjoin"); + } + Err(err) => { + tracing::error!("Could not proceed with payjoin proposal: {:?}", err); + } + } + } + else => () + } + Ok(()) + } + + pub async fn handle_proposal(&self, proposal: UncheckedProposal) -> Result<(), Error> { + let mut seen_inputs = self.seen_inputs.lock().await; + + let tx = proposal.extract_tx_to_schedule_broadcast(); + + let their_inputs: HashSet<_> = tx.input.iter().map(|tx_in| tx_in.previous_output).collect(); + + let mut payjoin = + BdkWallet::verify_proposal(Arc::clone(&self.wallet), proposal, seen_inputs.clone()) + .await?; + seen_inputs.extend(their_inputs); + drop(seen_inputs); + + tracing::debug!("Verified proposal"); + + let wallet = self.wallet.lock().await; + + // Augment the Proposal to Make a Batched Transaction + let available_inputs: Vec<_> = wallet.list_unspent().collect(); + tracing::debug!("{} available inputs to contribute", available_inputs.len()); + let candidate_inputs: HashMap = + available_inputs + .iter() + .map(|i| { + ( + payjoin::bitcoin::Amount::from_sat(i.txout.value.to_sat()), + payjoin::bitcoin::OutPoint { + txid: payjoin::bitcoin::Txid::from_str( + &i.outpoint.txid.to_raw_hash().to_string(), + ) + .unwrap(), + vout: i.outpoint.vout, + }, + ) + }) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).unwrap(); + let selected_utxo = available_inputs + .iter() + .find(|i| { + i.outpoint.txid.to_base32() == selected_outpoint.txid.to_base32() + && i.outpoint.vout == selected_outpoint.vout + }) + .unwrap(); + + let txo_to_contribute = payjoin::bitcoin::TxOut { + value: selected_utxo.txout.value.to_sat(), + script_pubkey: payjoin::bitcoin::ScriptBuf::from_bytes( + selected_utxo.clone().txout.script_pubkey.into_bytes(), + ), + }; + let outpoint_to_contribute = payjoin::bitcoin::OutPoint { + txid: payjoin::bitcoin::Txid::from_str( + &selected_utxo.outpoint.txid.to_raw_hash().to_string(), + ) + .unwrap(), + vout: selected_utxo.outpoint.vout, + }; + payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); + + let payjoin = payjoin + .finalize_proposal( + |psbt| { + let psbt = psbt.to_string(); + let mut psbt = bdk_wallet::bitcoin::psbt::Psbt::from_str(&psbt).unwrap(); + + let sign_options = SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + + if let Err(err) = wallet.sign(&mut psbt, sign_options.clone()) { + tracing::error!("Could not sign psbt: {}", err); + } + + if let Err(err) = wallet.finalize_psbt(&mut psbt, sign_options) { + tracing::debug!("Could not finalize transactions: {}", err); + } + + let psbt = payjoin::bitcoin::psbt::Psbt::from_str(&psbt.to_string()).unwrap(); + Ok(psbt) + }, + Some(payjoin::bitcoin::FeeRate::MIN), + ) + .map_err(|_| anyhow!("Could not finalize proposal"))?; + + self.send_payjoin_proposal(payjoin).await?; + + tracing::debug!("finalized transaction"); + + Ok(()) + } + + pub async fn send_payjoin_proposal(&self, payjoin: PayjoinProposal) -> Result<(), Error> { + let mut payjoin = payjoin; + let (req, ctx) = payjoin.extract_v2_req().unwrap(); + let http = reqwest::Client::new(); + let res = http + .post(req.url) + .body(req.body) + .header("Content-Type", payjoin::V2_REQ_CONTENT_TYPE) + .send() + .await + .unwrap(); + payjoin + .process_res(res.bytes().await.unwrap().to_vec(), ctx) + .unwrap(); + let payjoin_psbt = payjoin.psbt().clone(); + + println!( + "response successful. Watch mempool for successful payjoin. TXID: {}", + payjoin_psbt.extract_tx().clone().txid() + ); + + Ok(()) + } } #[async_trait] @@ -98,7 +496,7 @@ impl MintOnChain for BdkWallet { fn get_settings(&self) -> Settings { Settings { - mpp: true, + payjoin_settings: self.payjoin_settings.clone(), min_mint_amount: self.min_mint_amount, max_mint_amount: self.max_mint_amount, min_melt_amount: self.min_melt_amount, @@ -110,11 +508,31 @@ impl MintOnChain for BdkWallet { } /// New onchain address - async fn new_address(&self) -> Result { + async fn new_address(&self) -> Result { let mut wallet = self.wallet.lock().await; - Ok(wallet + let address = wallet .reveal_next_address(KeychainKind::External) - .to_string()) + .address + .to_string(); + + if let Some(changeset) = wallet.take_staged() { + let mut db = self.db.lock().await; + if let Err(err) = db.append_changeset(&changeset) { + tracing::error!("Could not update change set with new address: {}", err); + return Err(anyhow!("Could not update used address index").into()); + } + } + + let payjoin_url = match self.payjoin_settings.receive_enabled { + true => Some(self.start_payjoin(&address).await?), + false => None, + }; + + Ok(NewAddressResponse { + address, + payjoin_url, + pjos: None, + }) } /// Pay Address @@ -156,21 +574,13 @@ impl MintOnChain for BdkWallet { } /// Check if an address has been paid -<<<<<<< Updated upstream - async fn check_address_paid( - &self, - address: cdk::bitcoin::Address, - ) -> Result { - let address: Address = Address::from_str(&address.to_string()) - .unwrap() - .assume_checked(); -======= async fn check_address_paid(&self, address: &str) -> Result { let address: Address = Address::from_str(address).unwrap().assume_checked(); ->>>>>>> Stashed changes let script = address.script_pubkey(); + self.update_chain_tip().await?; + let transactions = self .client .scripthash_txs(script.as_script(), None) diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index e217888f0..b04994dfa 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -17,6 +17,7 @@ bip39.workspace = true cdk = { workspace = true, default-features = false, features = ["wallet"] } cdk-redb = { workspace = true, default-features = false, features = ["wallet"] } cdk-sqlite = { workspace = true, default-features = false, features = ["wallet"] } +payjoin.workspace = true clap = { version = "4.4.8", features = ["derive", "env"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 57b31070d..91e358541 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -5,23 +5,26 @@ use std::time::Duration; use anyhow::Result; use cdk::amount::SplitTarget; use cdk::cdk_database::{Error, WalletDatabase}; -use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; use cdk::url::UncheckedUrl; use cdk::wallet::multi_mint_wallet::WalletKey; use cdk::wallet::{MultiMintWallet, Wallet}; use cdk::Amount; use clap::Args; +use payjoin::{OhttpKeys, PjUriBuilder}; use tokio::time::sleep; -#[derive(Args)] +#[derive(Args, Debug)] pub struct MintSubCommand { /// Mint url mint_url: UncheckedUrl, /// Amount amount: u64, /// Currency unit e.g. sat - #[arg(default_value = "sat")] + #[arg(short, long, default_value = "sat")] unit: String, + #[arg(long, default_value = "bolt11")] + method: String, } pub async fn mint( @@ -31,8 +34,14 @@ pub async fn mint( sub_command_args: &MintSubCommand, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); + + println!("{:?}", sub_command_args); let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; + println!("unit"); + let method = PaymentMethod::from_str(&sub_command_args.method)?; + println!("heres"); + let wallet = match multi_mint_wallet .get_wallet(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat)) .await @@ -46,25 +55,85 @@ pub async fn mint( } }; - let quote = wallet - .mint_quote(Amount::from(sub_command_args.amount)) - .await?; + println!("here"); + let quote_id; - println!("Quote: {:#?}", quote); + match method { + PaymentMethod::Bolt11 => { + let quote = wallet + .mint_quote(Amount::from(sub_command_args.amount)) + .await?; + quote_id = quote.id.clone(); + println!("Quote: {:#?}", quote); - println!("Please pay: {}", quote.request); + println!("Please pay: {}", quote.request); - loop { - let status = wallet.mint_quote_state("e.id).await?; + loop { + let status = wallet.mint_quote_state("e.id).await?; - if status.state == MintQuoteState::Paid { - break; + if status.state == MintQuoteState::Paid { + break; + } + + sleep(Duration::from_secs(2)).await; + } } + PaymentMethod::BtcOnChain => { + let quote = wallet + .mint_onchain_quote(Amount::from(sub_command_args.amount)) + .await?; + quote_id = quote.quote.clone(); + println!("Quote: {:#?}", quote); + + match quote.payjoin { + Some(payjoin_info) => { + let ohttp_keys: Option = match payjoin_info.ohttp_relay { + Some(relay) => Some( + payjoin::io::fetch_ohttp_keys( + relay.clone().parse()?, + payjoin_info.origin.parse()?, + ) + .await?, + ), + None => None, + }; + + println!("ohttp keys: {:?}", ohttp_keys.clone().unwrap().to_string()); + + let address = payjoin::bitcoin::Address::from_str("e.address)?; + + let uri = PjUriBuilder::new( + address.assume_checked(), + payjoin_info.origin.parse()?, + ohttp_keys, + None, + ) + .amount(payjoin::bitcoin::Amount::from_sat(sub_command_args.amount)) + .pjos(false) + .build(); - sleep(Duration::from_secs(2)).await; - } + println!("Please pay: "); + println!("{}", uri); + } + + None => { + println!("please pay: {}", quote.address); + } + } + + loop { + let status = wallet.mint_onchain_quote_state("e.quote).await?; + + if status.state == MintQuoteState::Paid { + break; + } + + sleep(Duration::from_secs(2)).await; + } + } + }; - let receive_amount = wallet.mint("e.id, SplitTarget::default(), None).await?; + let receive_amount = wallet.mint("e_id, SplitTarget::default(), None).await?; println!("Received {receive_amount} from mint {mint_url}"); diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 98fb74bca..f6ad9b1c2 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -19,6 +19,7 @@ cdk-sqlite = { workspace = true, default-features = false, features = ["mint"] } cdk-cln = { workspace = true, default-features = false } cdk-fake-wallet = { workspace = true, default-features = false } cdk-axum = { workspace = true, default-features = false } +cdk-bdk = { workspace = true, default-features = false } config = { version = "0.13.3", features = ["toml"] } clap = { version = "4.4.8", features = ["derive", "env", "default"] } tokio.workspace = true diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 9562c4a42..bc49fdc6f 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -13,13 +13,15 @@ use axum::Router; use bip39::Mnemonic; use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_lightning::MintLightning; +use cdk::cdk_onchain::{MintOnChain, PayjoinSettings}; use cdk::mint::{FeeReserve, Mint}; use cdk::nuts::{ nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, MppMethodSettings, Nuts, PaymentMethod, }; -use cdk::{cdk_lightning, Amount}; +use cdk::{cdk_lightning, cdk_onchain, Amount}; use cdk_axum::LnKey; +use cdk_bdk::BdkWallet; use cdk_cln::Cln; use cdk_fake_wallet::FakeWallet; use cdk_redb::MintRedbDatabase; @@ -253,11 +255,41 @@ async fn main() -> anyhow::Result<()> { .seconds_quote_is_valid_for .unwrap_or(DEFAULT_QUOTE_TTL_SECS); + let payjoing_settings = PayjoinSettings { + receive_enabled: true, + send_enabled: false, + ohttp_relay: Some("https://pj.bobspacebkk.com".to_string()), + payjoin_directory: Some("https://payjo.in".to_string()), + }; + + let onchain = BdkWallet::new(0, 0, 0, 0, Mnemonic::parse("promote actress hand galaxy metal buzz square general outside business hard mother keen sound various").unwrap(), &work_dir, payjoing_settings) + .await + .unwrap(); + + let onchain_clone = onchain.clone(); + tokio::spawn(async move { + loop { + if let Err(err) = onchain_clone.wait_handle_proposal().await { + tracing::debug!("Handle proposal stopped: {}", err); + } + } + }); + + let mut onchain_backends: HashMap< + LnKey, + Arc + Sync + Send>, + > = HashMap::new(); + + onchain_backends.insert( + LnKey::new(CurrencyUnit::Sat, PaymentMethod::BtcOnChain), + Arc::new(onchain), + ); + let v1_service = cdk_axum::create_mint_router( &mint_url, Arc::clone(&mint), ln_backends, - HashMap::new(), + onchain_backends, quote_ttl, ) .await?; diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index e539eae30..a2cfd5d7a 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -29,12 +29,7 @@ ciborium = { version = "0.2.2", default-features = false, features = ["std"] } http = "1.0" lightning-invoice.workspace = true once_cell = "1.19" -reqwest = { version = "0.12", default-features = false, features = [ - "json", - "rustls-tls", - "rustls-tls-native-roots", - "socks", -], optional = true } +reqwest = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true serde_with = "3.4" diff --git a/crates/cdk/src/cdk_onchain/mod.rs b/crates/cdk/src/cdk_onchain/mod.rs index 09d18faca..edfee1fc3 100644 --- a/crates/cdk/src/cdk_onchain/mod.rs +++ b/crates/cdk/src/cdk_onchain/mod.rs @@ -1,7 +1,6 @@ //! CDK Mint Lightning use async_trait::async_trait; -use bitcoin::Address; use lightning_invoice::ParseOrSemanticError; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -39,7 +38,7 @@ pub trait MintOnChain { fn get_settings(&self) -> Settings; /// New onchain address - async fn new_address(&self) -> Result; + async fn new_address(&self) -> Result; /// Pay Address async fn pay_address( @@ -49,11 +48,18 @@ pub trait MintOnChain { ) -> Result; /// Check if an address has been paid -<<<<<<< Updated upstream - async fn check_address_paid(&self, address: Address) -> Result; -======= async fn check_address_paid(&self, address: &str) -> Result; ->>>>>>> Stashed changes +} + +/// New Address Response +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct NewAddressResponse { + /// Address + pub address: String, + /// Payjoin Url + pub payjoin_url: Option, + /// pjos for use with payjoin + pub pjos: Option, } /// Address paid response @@ -69,10 +75,8 @@ pub struct AddressPaidResponse { } /// Ln backend settings -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct Settings { - /// MPP supported - pub mpp: bool, /// Min amount to mint pub min_mint_amount: u64, /// Max amount to mint @@ -87,6 +91,21 @@ pub struct Settings { pub mint_enabled: bool, /// Melting enabled pub melt_enabled: bool, + /// Payjoin supported + pub payjoin_settings: PayjoinSettings, +} + +/// Payjoin settings +#[derive(Debug, Clone, Hash, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct PayjoinSettings { + /// Enable payjoin receive support + pub receive_enabled: bool, + /// Enable payjoin send support + pub send_enabled: bool, + /// Payjoin v2 ohttp relay + pub ohttp_relay: Option, + /// Payjoin v2 directory + pub payjoin_directory: Option, } const MSAT_IN_SAT: u64 = 1000; diff --git a/crates/cdk/src/nuts/nut17.rs b/crates/cdk/src/nuts/nut17.rs index e8b7823f2..2446a3d9f 100644 --- a/crates/cdk/src/nuts/nut17.rs +++ b/crates/cdk/src/nuts/nut17.rs @@ -5,8 +5,6 @@ use serde::{Deserialize, Serialize}; use super::{BlindSignature, BlindedMessage, CurrencyUnit, MintMethodSettings, MintQuoteState}; -#[cfg(feature = "mint")] -use crate::mint; use crate::Amount; /// Mint quote request [NUT-17] @@ -27,17 +25,19 @@ pub struct MintQuoteBtcOnchainResponse { pub address: String, /// Whether the the request has been paid pub state: MintQuoteState, + /// Payjoin + pub payjoin: Option, } -#[cfg(feature = "mint")] -impl From for MintQuoteBtcOnchainResponse { - fn from(mint_quote: mint::MintQuote) -> MintQuoteBtcOnchainResponse { - MintQuoteBtcOnchainResponse { - quote: mint_quote.id, - address: mint_quote.request, - state: mint_quote.state, - } - } +/// Payjoin information +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PayjoinInfo { + /// Origin Directory in v2 + pub origin: String, + /// Ohttp keys + pub ohttp_relay: Option, + /// PJO + pub pjos: bool, } /// Mint request [NUT-17] diff --git a/crates/cdk/src/wallet/client.rs b/crates/cdk/src/wallet/client.rs index 18f5c7fd8..1d47b603b 100644 --- a/crates/cdk/src/wallet/client.rs +++ b/crates/cdk/src/wallet/client.rs @@ -9,6 +9,7 @@ use super::Error; use crate::error::ErrorResponse; use crate::nuts::nut05::MeltBolt11Response; use crate::nuts::nut15::Mpp; +use crate::nuts::nut17::{MintQuoteBtcOnchainRequest, MintQuoteBtcOnchainResponse}; use crate::nuts::{ BlindedMessage, CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeySet, KeysResponse, KeysetResponse, MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, @@ -122,6 +123,36 @@ impl HttpClient { } } + /// Mint Onchain Quote [NUT-XX] + #[instrument(skip(self), fields(mint_url = %mint_url))] + pub async fn post_mint_onchain_quote( + &self, + mint_url: Url, + amount: Amount, + unit: CurrencyUnit, + ) -> Result { + let url = join_url(mint_url, &["v1", "mint", "quote", "onchain"])?; + + let request = MintQuoteBtcOnchainRequest { amount, unit }; + + let res = self + .inner + .post(url) + .json(&request) + .send() + .await? + .json::() + .await?; + + match serde_json::from_value::(res.clone()) { + Ok(mint_quote_response) => Ok(mint_quote_response), + Err(err) => { + tracing::warn!("{}", err); + Err(ErrorResponse::from_value(res)?.into()) + } + } + } + /// Mint Quote status #[instrument(skip(self), fields(mint_url = %mint_url))] pub async fn get_mint_quote_status( @@ -142,6 +173,26 @@ impl HttpClient { } } + /// Mint Quote status + #[instrument(skip(self), fields(mint_url = %mint_url))] + pub async fn get_mint_onchain_quote_status( + &self, + mint_url: Url, + quote_id: &str, + ) -> Result { + let url = join_url(mint_url, &["v1", "mint", "quote", "onchain", quote_id])?; + + let res = self.inner.get(url).send().await?.json::().await?; + + match serde_json::from_value::(res.clone()) { + Ok(mint_quote_response) => Ok(mint_quote_response), + Err(err) => { + tracing::warn!("{}", err); + Err(ErrorResponse::from_value(res)?.into()) + } + } + } + /// Mint Tokens [NUT-04] #[instrument(skip(self, quote, premint_secrets), fields(mint_url = %mint_url))] pub async fn post_mint( diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index c993e25da..4b4460069 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -18,6 +18,7 @@ use crate::amount::SplitTarget; use crate::cdk_database::{self, WalletDatabase}; use crate::dhke::{construct_proofs, hash_to_curve}; use crate::nuts::nut00::token::Token; +use crate::nuts::nut17::MintQuoteBtcOnchainResponse; use crate::nuts::{ nut10, nut12, Conditions, CurrencyUnit, Id, KeySetInfo, Keys, Kind, MeltQuoteBolt11Response, MeltQuoteState, MintInfo, MintQuoteBolt11Response, MintQuoteState, PreMintSecrets, PreSwap, @@ -461,6 +462,34 @@ impl Wallet { Ok(quote) } + /// Mint Quote + #[instrument(skip(self))] + pub async fn mint_onchain_quote( + &self, + amount: Amount, + ) -> Result { + let mint_url = self.mint_url.clone(); + let unit = self.unit; + let quote_res = self + .client + .post_mint_onchain_quote(mint_url.clone().try_into()?, amount, unit) + .await?; + + let quote = MintQuote { + mint_url, + id: quote_res.quote.clone(), + amount, + unit, + request: quote_res.address.clone(), + state: quote_res.state, + expiry: 0, + }; + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote_res) + } + /// Mint quote status #[instrument(skip(self, quote_id))] pub async fn mint_quote_state(&self, quote_id: &str) -> Result { @@ -484,6 +513,32 @@ impl Wallet { Ok(response) } + /// Mint quote status + #[instrument(skip(self, quote_id))] + pub async fn mint_onchain_quote_state( + &self, + quote_id: &str, + ) -> Result { + let response = self + .client + .get_mint_onchain_quote_status(self.mint_url.clone().try_into()?, quote_id) + .await?; + + match self.localstore.get_mint_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + + quote.state = response.state; + self.localstore.add_mint_quote(quote).await?; + } + None => { + tracing::info!("Quote mint {} unknown", quote_id); + } + } + + Ok(response) + } + /// Check status of pending mint quotes #[instrument(skip(self))] pub async fn check_all_mint_quotes(&self) -> Result {