From ffb8d182a16809aa68f24c9ff65657f85b8dc1d3 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Fri, 30 Jan 2026 16:36:06 -0500 Subject: [PATCH 01/10] Add payjoin dep in lianad --- lianad/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lianad/Cargo.toml b/lianad/Cargo.toml index 0f851e3eb..e5edd61cc 100644 --- a/lianad/Cargo.toml +++ b/lianad/Cargo.toml @@ -52,3 +52,7 @@ jsonrpc = { workspace = true, features = ["minreq_http"], default-features = fal # import/export labels bip329 = { workspace = true, default-features = false } + +# payjoin +payjoin = { version = "1.0.0-rc.1", features = ["v2", "io"] } +reqwest = { version = "0.11", default-features=false, features = ["json", "rustls-tls", "stream", "blocking"] } From 5210c6c9543fc3a7739945050958608ca7c4d368 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Fri, 30 Jan 2026 16:36:29 -0500 Subject: [PATCH 02/10] Add payjoin support within lianad --- lianad/src/bitcoin/d/mod.rs | 16 +- lianad/src/bitcoin/mod.rs | 17 ++ lianad/src/bitcoin/poller/looper.rs | 30 +- lianad/src/bitcoin/poller/mod.rs | 13 +- lianad/src/commands/mod.rs | 118 +++++++- lianad/src/database/mod.rs | 93 +++++++ lianad/src/database/sqlite/mod.rs | 223 +++++++++++++++ lianad/src/database/sqlite/schema.rs | 53 ++++ lianad/src/jsonrpc/api.rs | 47 ++++ lianad/src/jsonrpc/rpc.rs | 14 +- lianad/src/lib.rs | 1 + lianad/src/payjoin/db.rs | 85 ++++++ lianad/src/payjoin/helpers.rs | 130 +++++++++ lianad/src/payjoin/mod.rs | 4 + lianad/src/payjoin/receiver.rs | 392 +++++++++++++++++++++++++++ lianad/src/payjoin/types.rs | 40 +++ lianad/src/testutils.rs | 65 +++++ 17 files changed, 1307 insertions(+), 34 deletions(-) create mode 100644 lianad/src/payjoin/db.rs create mode 100644 lianad/src/payjoin/helpers.rs create mode 100644 lianad/src/payjoin/mod.rs create mode 100644 lianad/src/payjoin/receiver.rs create mode 100644 lianad/src/payjoin/types.rs diff --git a/lianad/src/bitcoin/d/mod.rs b/lianad/src/bitcoin/d/mod.rs index 427a16248..850a3de24 100644 --- a/lianad/src/bitcoin/d/mod.rs +++ b/lianad/src/bitcoin/d/mod.rs @@ -721,7 +721,6 @@ impl BitcoinD { bitcoin::Network::Testnet4 => "testnet4", bitcoin::Network::Regtest => "regtest", bitcoin::Network::Signet => "signet", - _ => "Unknown network, undefined at the time of writing", }; if bitcoind_net != bip70_net { return Err(BitcoindError::NetworkMismatch( @@ -1231,6 +1230,21 @@ impl BitcoinD { .collect() } + /// Test whether raw transactions would be accepted by the mempool. + pub fn test_mempool_accept(&self, rawtxs: Vec) -> Vec { + let hex_txs: Json = rawtxs.into_iter().map(|tx| serde_json::json!(tx)).collect(); + self.make_node_request("testmempoolaccept", params!(hex_txs)) + .as_array() + .expect("Always returns an array") + .iter() + .map(|e| { + e.get("allowed") + .and_then(|v| v.as_bool()) + .expect("Each result must have an 'allowed' boolean") + }) + .collect() + } + /// Stop bitcoind. pub fn stop(&self) { self.make_node_request("stop", None); diff --git a/lianad/src/bitcoin/mod.rs b/lianad/src/bitcoin/mod.rs index 26e755df8..742ff51f6 100644 --- a/lianad/src/bitcoin/mod.rs +++ b/lianad/src/bitcoin/mod.rs @@ -133,6 +133,11 @@ pub trait BitcoinInterface: Send { /// /// Returns `None` if the transaction is not in the mempool. fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option; + + /// Test if given raw txs will be accepted by mempool. + /// + /// Returns `None` if the transaction is not in the mempool. + fn test_mempool_accept(&self, rawtxs: Vec) -> Vec; } impl BitcoinInterface for d::BitcoinD { @@ -402,6 +407,10 @@ impl BitcoinInterface for d::BitcoinD { fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option { self.mempool_entry(txid) } + + fn test_mempool_accept(&self, rawtxs: Vec) -> Vec { + self.test_mempool_accept(rawtxs) + } } impl BitcoinInterface for electrum::Electrum { @@ -589,6 +598,10 @@ impl BitcoinInterface for electrum::Electrum { fn tip_time(&self) -> Option { self.client().tip_time().ok() } + + fn test_mempool_accept(&self, _rawtxs: Vec) -> Vec { + todo!() + } } // FIXME: do we need to repeat the entire trait implementation? Isn't there a nicer way? @@ -694,6 +707,10 @@ impl BitcoinInterface for sync::Arc> fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option { self.lock().unwrap().mempool_entry(txid) } + + fn test_mempool_accept(&self, rawtxs: Vec) -> Vec { + self.lock().unwrap().test_mempool_accept(rawtxs) + } } // FIXME: We could avoid this type (and all the conversions entailing allocations) if bitcoind diff --git a/lianad/src/bitcoin/poller/looper.rs b/lianad/src/bitcoin/poller/looper.rs index 8654a6b49..d18e7bf19 100644 --- a/lianad/src/bitcoin/poller/looper.rs +++ b/lianad/src/bitcoin/poller/looper.rs @@ -1,6 +1,7 @@ use crate::{ bitcoin::{BitcoinInterface, BlockChainTip, UTxO, UTxOAddress}, database::{Coin, DatabaseConnection, DatabaseInterface}, + payjoin::receiver::payjoin_receiver_check, }; use std::{collections::HashSet, convert::TryInto, sync, thread, time}; @@ -28,7 +29,7 @@ fn update_coins( bit: &impl BitcoinInterface, db_conn: &mut Box, previous_tip: &BlockChainTip, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, secp: &secp256k1::Secp256k1, ) -> UpdatedCoins { let network = db_conn.network(); @@ -36,6 +37,10 @@ fn update_coins( log::debug!("Current coins: {:?}", curr_coins); // Start by fetching newly received coins. + let descs = &[ + desc.receive_descriptor().clone(), + desc.change_descriptor().clone(), + ]; let mut received = Vec::new(); for utxo in bit.received_coins(previous_tip, descs) { let UTxO { @@ -241,7 +246,7 @@ fn new_tip(bit: &impl BitcoinInterface, current_tip: &BlockChainTip) -> TipUpdat fn updates( db_conn: &mut Box, bit: &mut impl BitcoinInterface, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, secp: &secp256k1::Secp256k1, ) { // Check if there was a new block before we update our state. @@ -264,7 +269,7 @@ fn updates( // between our former chain and the new one, then restart fresh. db_conn.rollback_tip(&new_tip); log::info!("Tip was rolled back to '{}'.", new_tip); - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } } } @@ -285,23 +290,23 @@ fn updates( &reorg_common_ancestor ); } - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } Err(e) => { log::error!("Error syncing wallet: '{}'.", e); thread::sleep(time::Duration::from_secs(2)); - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } }; // Then check the state of our coins. Do it even if the tip did not change since last poll, as // we may have unconfirmed transactions. - let updated_coins = update_coins(bit, db_conn, ¤t_tip, descs, secp); + let updated_coins = update_coins(bit, db_conn, ¤t_tip, desc, secp); // If the tip changed while we were polling our Bitcoin interface, start over. if bit.chain_tip() != latest_tip { log::info!("Chain tip changed while we were updating our state. Starting over."); - return updates(db_conn, bit, descs, secp); + return updates(db_conn, bit, desc, secp); } // Transactions must be added to the DB before coins due to foreign key constraints. @@ -330,7 +335,7 @@ fn updates( fn rescan_check( db_conn: &mut Box, bit: &mut impl BitcoinInterface, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, secp: &secp256k1::Secp256k1, ) { log::debug!("Checking the state of an ongoing rescan if there is any"); @@ -368,7 +373,7 @@ fn rescan_check( "Rolling back our internal tip to '{}' to update our internal state with past transactions.", rescan_tip ); - updates(db_conn, bit, descs, secp) + updates(db_conn, bit, desc, secp) } else { log::debug!("No ongoing rescan."); } @@ -399,11 +404,12 @@ pub fn poll( bit: &mut sync::Arc>, db: &sync::Arc>, secp: &secp256k1::Secp256k1, - descs: &[descriptors::SinglePathLianaDesc], + desc: &descriptors::LianaDescriptor, ) { let mut db_conn = db.connection(); - updates(&mut db_conn, bit, descs, secp); - rescan_check(&mut db_conn, bit, descs, secp); + updates(&mut db_conn, bit, desc, secp); + rescan_check(&mut db_conn, bit, desc, secp); + payjoin_receiver_check(db, bit, desc, secp); let now: u32 = time::SystemTime::now() .duration_since(time::UNIX_EPOCH) .expect("current system time must be later than epoch") diff --git a/lianad/src/bitcoin/poller/mod.rs b/lianad/src/bitcoin/poller/mod.rs index bbc11933e..f606e7758 100644 --- a/lianad/src/bitcoin/poller/mod.rs +++ b/lianad/src/bitcoin/poller/mod.rs @@ -23,8 +23,7 @@ pub struct Poller { bit: sync::Arc>, db: sync::Arc>, secp: secp256k1::Secp256k1, - // The receive and change descriptors (in this order). - descs: [descriptors::SinglePathLianaDesc; 2], + desc: descriptors::LianaDescriptor, } impl Poller { @@ -34,10 +33,6 @@ impl Poller { desc: descriptors::LianaDescriptor, ) -> Poller { let secp = secp256k1::Secp256k1::verification_only(); - let descs = [ - desc.receive_descriptor().clone(), - desc.change_descriptor().clone(), - ]; // On first startup the tip may be NULL. Make sure it's set as the poller relies on it. looper::maybe_initialize_tip(&bit, &db); @@ -46,7 +41,7 @@ impl Poller { bit, db, secp, - descs, + desc, } } @@ -108,7 +103,7 @@ impl Poller { // poll too soon. last_poll = Some(time::Instant::now()); if synced { - looper::poll(&mut self.bit, &self.db, &self.secp, &self.descs); + looper::poll(&mut self.bit, &self.db, &self.secp, &self.desc); } else { log::warn!("Skipped poll as block chain is still synchronizing."); } @@ -142,7 +137,7 @@ impl Poller { } } - looper::poll(&mut self.bit, &self.db, &self.secp, &self.descs); + looper::poll(&mut self.bit, &self.db, &self.secp, &self.desc); } } } diff --git a/lianad/src/commands/mod.rs b/lianad/src/commands/mod.rs index c25afeab1..21067930a 100644 --- a/lianad/src/commands/mod.rs +++ b/lianad/src/commands/mod.rs @@ -8,6 +8,11 @@ use crate::{ bitcoin::BitcoinInterface, database::{Coin, DatabaseConnection, DatabaseInterface}, miniscript::bitcoin::absolute::LockTime, + payjoin::{ + db::ReceiverPersister, + helpers::{fetch_ohttp_keys, FetchOhttpKeysError, OHTTP_RELAY, PAYJOIN_DIRECTORY}, + types::PayjoinStatus, + }, poller::PollerMessage, DaemonControl, VERSION, }; @@ -31,7 +36,7 @@ use std::{ collections::{hash_map, HashMap, HashSet}, convert::TryInto, fmt, - sync::{self, mpsc}, + sync::{self, mpsc, Arc}, time::SystemTime, }; @@ -43,6 +48,7 @@ use miniscript::{ }, psbt::PsbtExt, }; +use payjoin::receive::v2::{replay_event_log as replay_receiver_event_log, ReceiverBuilder}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -74,6 +80,11 @@ pub enum CommandError { InvalidDerivationIndex, RbfError(RbfErrorInfo), EmptyFilterList, + FailedToFetchOhttpKeys(FetchOhttpKeysError), + // Same FIXME as `SpendFinalization` + FailedToPostOriginalPayjoinProposal(String), + ReplayError(String), + IntoUrlError(String), } impl fmt::Display for CommandError { @@ -132,6 +143,16 @@ impl fmt::Display for CommandError { } Self::RbfError(e) => write!(f, "RBF error: '{}'.", e), Self::EmptyFilterList => write!(f, "Filter list is empty, should supply None instead."), + Self::FailedToFetchOhttpKeys(e) => write!(f, "Failed to fetch OHTTP keys: '{}'.", e), + Self::FailedToPostOriginalPayjoinProposal(e) => { + write!(f, "Failed to post original payjoin proposal: '{}'.", e) + } + Self::ReplayError(e) => { + write!(f, "Payjoin replay failed: '{}'.", e) + } + Self::IntoUrlError(e) => { + write!(f, "Payjoin into url failed: '{}'.", e) + } } } } @@ -359,7 +380,78 @@ impl DaemonControl { .receive_descriptor() .derive(new_index, &self.secp) .address(self.config.bitcoin_config.network); - GetAddressResult::new(address, new_index) + GetAddressResult::new(address, new_index, None) + } + + /// Begin receive payjoin flow + pub fn receive_payjoin(&self) -> Result { + let mut db_conn = self.db.connection(); + + let ohttp_keys = if let Some(entry) = db_conn.payjoin_get_ohttp_keys(OHTTP_RELAY) { + entry.1 + } else { + let ohttp_keys = + std::thread::spawn(move || fetch_ohttp_keys(OHTTP_RELAY, PAYJOIN_DIRECTORY)) + .join() + .unwrap() + .map_err(CommandError::FailedToFetchOhttpKeys)?; + db_conn.payjoin_save_ohttp_keys(OHTTP_RELAY, ohttp_keys.clone()); + ohttp_keys + }; + + let index = db_conn.receive_index(); + let new_index = index + .increment() + .expect("Can't get into hardened territory"); + db_conn.set_receive_index(new_index, &self.secp); + let address = self + .config + .main_descriptor + .receive_descriptor() + .derive(new_index, &self.secp) + .address(self.config.bitcoin_config.network); + + let persister = ReceiverPersister::new(Arc::new(self.db.clone()), new_index.into(), ""); + let session = ReceiverBuilder::new(address.clone(), PAYJOIN_DIRECTORY, ohttp_keys) + .map_err(|e| CommandError::IntoUrlError(e.to_string()))? + .build() + .save(&persister) + .unwrap(); + + let bip21 = session.pj_uri().to_string(); + + let mut db_conn = self.db.connection(); + db_conn.update_payjoin_receiver_bip21(new_index.into(), &bip21); + + Ok(GetAddressResult::new(address, new_index, Some(bip21))) + } + + /// Get Payjoin URI (BIP21) and its sender/receiver status by txid + pub fn get_payjoin_info(&self, txid: &bitcoin::Txid) -> Result { + let mut db_conn = self.db.connection(); + log::debug!("Getting payjoin info for txid: {:?}", txid); + if let Some(session_id) = db_conn.get_payjoin_receiver_session_id_from_txid(txid) { + let persister = + ReceiverPersister::from_id(Arc::new(self.db.clone()), session_id.clone()); + let (state, _) = replay_receiver_event_log(&persister) + .map_err(|e| CommandError::ReplayError(format!("Receiver replay failed: {e:?}")))?; + return Ok(state.into()); + } + + Ok(PayjoinStatus::Unknown) + } + + /// Get all active payjoin receiver sessions with their derivation indexes + pub fn get_active_payjoin_sessions(&self) -> Result, CommandError> { + let mut db_conn = self.db.connection(); + let sessions = db_conn.get_active_payjoin_sessions(); + Ok(sessions.into_iter().map(|(_, idx)| idx).collect()) + } + + /// Get payjoin BIP21 URI for a specific derivation index + pub fn get_payjoin_bip21(&self, derivation_index: u32) -> Result, CommandError> { + let mut db_conn = self.db.connection(); + Ok(db_conn.get_payjoin_receiver_bip21(derivation_index)) } /// Update derivation indexes @@ -901,14 +993,12 @@ impl DaemonControl { let mut spend_psbt = db_conn .spend_tx(txid) .ok_or(CommandError::UnknownSpend(*txid))?; - spend_psbt.finalize_mut(&self.secp).map_err(|e| { - CommandError::SpendFinalization( - e.into_iter() - .next() - .map(|e| e.to_string()) - .unwrap_or_default(), - ) - })?; + for index in 0..spend_psbt.inputs.len() { + match spend_psbt.finalize_inp_mut(&self.secp, index) { + Ok(_) => log::debug!("Finalizing input at: {}", index), + Err(e) => log::error!("Not finalizing input at: {} | {}", index, e), + } + } // Then, broadcast it (or try to, we never know if we are not going to hit an // error at broadcast time). @@ -1372,13 +1462,19 @@ pub struct GetAddressResult { #[serde(deserialize_with = "deser_addr_assume_checked")] pub address: bitcoin::Address, pub derivation_index: bip32::ChildNumber, + pub bip21: Option, } impl GetAddressResult { - pub fn new(address: bitcoin::Address, derivation_index: bip32::ChildNumber) -> Self { + pub fn new( + address: bitcoin::Address, + derivation_index: bip32::ChildNumber, + bip21: Option, + ) -> Self { Self { address, derivation_index, + bip21, } } } diff --git a/lianad/src/database/mod.rs b/lianad/src/database/mod.rs index 57c4a835a..1efc4777a 100644 --- a/lianad/src/database/mod.rs +++ b/lianad/src/database/mod.rs @@ -10,6 +10,7 @@ use crate::{ schema::{DbBlockInfo, DbCoin, DbTip}, SqliteConn, SqliteDb, }, + payjoin::db::SessionId, }; use std::{ @@ -22,6 +23,7 @@ use std::{ use bip329::Labels; use miniscript::bitcoin::{self, bip32, psbt::Psbt, secp256k1, Address, Network, OutPoint, Txid}; +use payjoin::OhttpKeys; /// Information about the wallet. /// @@ -194,6 +196,46 @@ pub trait DatabaseConnection { /// Dump all labels fn get_labels_bip329(&mut self, offset: u32, limit: u32) -> Labels; + + /// Get the next Session Id + fn payjoin_get_ohttp_keys(&mut self, ohttp_relay: &str) -> Option<(u32, OhttpKeys)>; + + /// Save OHttpKeys + fn payjoin_save_ohttp_keys(&mut self, ohttp_relay: &str, ohttp_keys: OhttpKeys); + + /// Save Receiver Session + fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32, bip21: &str) -> i64; + + /// Get bip21 for a receiver session by derivation index + fn get_payjoin_receiver_bip21(&mut self, derivation_index: u32) -> Option; + + /// Update bip21 for a receiver session + fn update_payjoin_receiver_bip21(&mut self, derivation_index: u32, bip21: &str); + + /// Get active payjoin sessions with their derivation indexes + fn get_active_payjoin_sessions(&mut self) -> Vec<(SessionId, u32)>; + + /// Get receiver session id from txid -- this will return the session id if the txid is a proposed payjoin txid or the original txid + fn get_payjoin_receiver_session_id_from_txid( + &mut self, + txid: &bitcoin::Txid, + ) -> Option; + + /// Get all Receiver Sessions + fn get_all_active_receiver_session_ids(&mut self) -> Vec; + + /// Save a Receiver Session Event + fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec); + + /// Update completed at timestamp for a Receiver Session + /// Sets completed_at to current timestamp + fn update_receiver_session_completed_at(&mut self, session_id: &SessionId); + + /// Load all receiver session events for a particular session id + fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec>; + + /// Check if input has been seen before and then add it to the input_seen table + fn insert_input_seen_before(&mut self, outpoints: &[bitcoin::OutPoint]) -> bool; } impl DatabaseConnection for SqliteConn { @@ -416,6 +458,57 @@ impl DatabaseConnection for SqliteConn { }) .collect() } + + fn insert_input_seen_before(&mut self, outpoints: &[bitcoin::OutPoint]) -> bool { + self.insert_outpoint_seen_before(outpoints) + } + + fn payjoin_get_ohttp_keys(&mut self, ohttp_relay: &str) -> Option<(u32, OhttpKeys)> { + self.payjoin_get_ohttp_keys(ohttp_relay) + } + + fn payjoin_save_ohttp_keys(&mut self, ohttp_relay: &str, ohttp_keys: OhttpKeys) { + self.payjoin_save_ohttp_keys(ohttp_relay, ohttp_keys) + } + + fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32, bip21: &str) -> i64 { + self.save_new_payjoin_receiver_session(derivation_index, bip21) + } + + fn get_payjoin_receiver_bip21(&mut self, derivation_index: u32) -> Option { + self.get_payjoin_receiver_bip21(derivation_index) + } + + fn update_payjoin_receiver_bip21(&mut self, derivation_index: u32, bip21: &str) { + self.update_payjoin_receiver_bip21(derivation_index, bip21) + } + + fn get_active_payjoin_sessions(&mut self) -> Vec<(SessionId, u32)> { + self.get_active_payjoin_sessions() + } + + fn get_all_active_receiver_session_ids(&mut self) -> Vec { + self.get_all_active_receiver_session_ids() + } + + fn get_payjoin_receiver_session_id_from_txid( + &mut self, + txid: &bitcoin::Txid, + ) -> Option { + self.get_payjoin_receiver_session_id_from_txid(txid) + } + + fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec) { + self.save_receiver_session_event(session_id, event) + } + + fn update_receiver_session_completed_at(&mut self, session_id: &SessionId) { + self.update_receiver_session_completed_at(session_id) + } + + fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec> { + self.load_receiver_session_events(session_id) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/lianad/src/database/sqlite/mod.rs b/lianad/src/database/sqlite/mod.rs index 5f6876ee0..2a89fbe69 100644 --- a/lianad/src/database/sqlite/mod.rs +++ b/lianad/src/database/sqlite/mod.rs @@ -25,8 +25,11 @@ use crate::{ }, Coin, CoinStatus, LabelItem, }, + payjoin::db::SessionId, }; use liana::descriptors::LianaDescriptor; +use payjoin::{bitcoin::consensus::Encodable, OhttpKeys}; +use serde_json; use std::{ cmp, @@ -479,6 +482,34 @@ impl SqliteConn { .expect("Database must be available") } + pub fn insert_outpoint_seen_before<'a>( + &mut self, + outpoints: impl IntoIterator, + ) -> bool { + let mut is_duplicate = false; + db_exec(&mut self.conn, |db_tx| { + for outpoint in outpoints { + let mut buf = Vec::new(); + outpoint + .consensus_encode(&mut buf) + .expect("Outpoint must encode"); + let affected = db_tx.execute( + "INSERT OR IGNORE INTO payjoin_outpoints (outpoint, added_at) \ + VALUES (?1, ?2)", + rusqlite::params![buf, curr_timestamp()], + )?; + + if affected == 0 { + is_duplicate = true + } + } + Ok(()) + }) + .expect("database must be available"); + + is_duplicate + } + /// Remove a set of coins from the database. pub fn remove_coins(&mut self, outpoints: &[bitcoin::OutPoint]) { db_exec(&mut self.conn, |db_tx| { @@ -963,6 +994,198 @@ impl SqliteConn { }) .expect("Db must not fail"); } + + /// Fetch Payjoin OHttpKeys and their timestamp + pub fn payjoin_get_ohttp_keys(&mut self, relay_url: &str) -> Option<(u32, OhttpKeys)> { + let entries = db_query( + &mut self.conn, + "SELECT timestamp, key FROM payjoin_ohttp_keys WHERE relay_url = ?1 ORDER BY timestamp DESC LIMIT 1", + rusqlite::params![relay_url], + |row| { + let timestamp: u32 = row.get(0)?; + let ohttp_keys_ser: Vec = row.get(1)?; + let ohttp_keys = OhttpKeys::decode(&ohttp_keys_ser).unwrap(); + Ok((timestamp, ohttp_keys)) + }, + ) + .expect("Db must not fail"); + + // Check timestamp (7-days) + if let Some(entry) = entries.first().cloned() { + let now = curr_timestamp(); + let seven_days_ago = now.saturating_sub(7 * 24 * 60 * 60); + if entry.0 < seven_days_ago { + // Delete entry + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "DELETE FROM payjoin_ohttp_keys WHERE relay_url = ?1", + rusqlite::params![relay_url], + )?; + Ok(()) + }) + .expect("Db must not fail"); + return None; + } else { + return Some(entry); + } + } + None + } + + /// Store new OHttpKeys with timestamp + pub fn payjoin_save_ohttp_keys(&mut self, relay_url: &str, ohttp_keys: OhttpKeys) { + let ohttp_keys_ser = ohttp_keys.encode().unwrap(); + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "INSERT INTO payjoin_ohttp_keys (relay_url, timestamp, key) VALUES (?1, ?2, ?3)", + rusqlite::params![relay_url, curr_timestamp(), ohttp_keys_ser], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } + + /// Get all active receiver session ids + pub fn get_all_active_receiver_session_ids(&mut self) -> Vec { + db_query( + &mut self.conn, + "SELECT id FROM payjoin_receivers WHERE completed_at IS NULL", + rusqlite::params![], + |row| { + let id: i64 = row.get(0)?; + Ok(SessionId::new(id)) + }, + ) + .expect("Db must not fail") + } + + /// Save a Receiver Session Event + pub fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec) { + db_exec(&mut self.conn, |db_tx| { + let events: Vec> = db_tx + .query_row( + "SELECT events FROM payjoin_receivers WHERE id = ?1", + rusqlite::params![session_id.0], + |row| { + let events_json: String = row.get(0)?; + Ok(serde_json::from_str(&events_json).unwrap_or_default()) + }, + ) + .unwrap_or_default(); + let mut events = events; + events.push(event); + let events_json = serde_json::to_string(&events).unwrap(); + db_tx.execute( + "UPDATE payjoin_receivers SET events = ?1 WHERE id = ?2", + rusqlite::params![events_json, session_id.0], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } + + /// Update completed at timestamp for a Receiver Session + pub fn update_receiver_session_completed_at(&mut self, session_id: &SessionId) { + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "UPDATE payjoin_receivers SET completed_at = ?1 WHERE id = ?2", + rusqlite::params![curr_timestamp(), session_id.0], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } + + /// Load all receiver session events for a particular session id + pub fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec> { + db_query( + &mut self.conn, + "SELECT events FROM payjoin_receivers WHERE id = ?1", + rusqlite::params![session_id.0], + |row| { + let events_json: String = row.get(0)?; + Ok(serde_json::from_str(&events_json).unwrap_or_default()) + }, + ) + .expect("Db must not fail") + .into_iter() + .next() + .unwrap_or_default() + } + + /// Get receiver session id from txid + pub fn get_payjoin_receiver_session_id_from_txid( + &mut self, + txid: &bitcoin::Txid, + ) -> Option { + let sessions = self.get_active_payjoin_sessions(); + let txid_bytes = txid[..].to_vec(); + for (session_id, _) in sessions { + let events = self.load_receiver_session_events(&session_id); + for event in events { + if event + .windows(txid_bytes.len()) + .any(|w| w == txid_bytes.as_slice()) + { + return Some(session_id); + } + } + } + None + } + + /// Create new Receiver Session + pub fn save_new_payjoin_receiver_session( + &mut self, + derivation_index: u32, + _bip21: &str, + ) -> i64 { + let mut id = 0i64; + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "INSERT INTO payjoin_receivers (derivation_index, created_at) VALUES (?1, ?2)", + rusqlite::params![derivation_index, curr_timestamp()], + )?; + + id = db_tx.last_insert_rowid(); + Ok(()) + }) + .expect("Db must not fail"); + id + } + + /// Get bip21 for a receiver session by derivation index + pub fn get_payjoin_receiver_bip21(&mut self, derivation_index: u32) -> Option { + db_query( + &mut self.conn, + "SELECT receive_address FROM addresses WHERE derivation_index = ?1", + rusqlite::params![derivation_index], + |row| row.get(0), + ) + .expect("Db must not fail") + .into_iter() + .next() + } + + /// Update bip21 for a receiver session (no-op, kept for API compatibility) + pub fn update_payjoin_receiver_bip21(&mut self, _derivation_index: u32, _bip21: &str) { + // bip21 is now computed on-the-fly, no-op + } + + /// Get all active receiver session ids with their derivation indexes + pub fn get_active_payjoin_sessions(&mut self) -> Vec<(SessionId, u32)> { + db_query( + &mut self.conn, + "SELECT id, derivation_index FROM payjoin_receivers WHERE completed_at IS NULL AND derivation_index IS NOT NULL", + rusqlite::params![], + |row| { + let id: i64 = row.get(0)?; + let derivation_index: u32 = row.get(1)?; + Ok((SessionId::new(id), derivation_index)) + }, + ) + .expect("Db must not fail") + } } #[cfg(test)] diff --git a/lianad/src/database/sqlite/schema.rs b/lianad/src/database/sqlite/schema.rs index c80beb8e4..495a65abd 100644 --- a/lianad/src/database/sqlite/schema.rs +++ b/lianad/src/database/sqlite/schema.rs @@ -1,5 +1,6 @@ use bip329::Label; use liana::descriptors::LianaDescriptor; +use payjoin::bitcoin::{consensus::Decodable, io::Cursor}; use std::{convert::TryFrom, str::FromStr}; @@ -87,6 +88,16 @@ CREATE TABLE coins ( ON DELETE RESTRICT ); +/* Seen Payjoin outpoints + * + * The 'created_at' field is simply the time that this outpoint is added to the table for + * tracking. + */ +CREATE TABLE payjoin_outpoints ( + outpoint BLOB NOT NULL PRIMARY KEY, + created_at INTEGER NOT NULL +); + /* A mapping from descriptor address to derivation index. Necessary until * we can get the derivation index from the parent descriptor from bitcoind. */ @@ -122,6 +133,24 @@ CREATE TABLE labels ( item TEXT UNIQUE NOT NULL, value TEXT NOT NULL ); + +/* Payjoin OHttpKeys */ +CREATE TABLE payjoin_ohttp_keys ( + id INTEGER PRIMARY KEY NOT NULL, + relay_url TEXT UNIQUE NOT NULL, + timestamp INTEGER NOT NULL, + key BLOB NOT NULL +); + +/* Payjoin receivers */ +CREATE TABLE payjoin_receivers ( + id INTEGER PRIMARY KEY NOT NULL, + derivation_index INTEGER NOT NULL, + created_at INTEGER NOT NULL, + completed_at INTEGER, + events TEXT NOT NULL DEFAULT '[]', + FOREIGN KEY (derivation_index) REFERENCES addresses (derivation_index) +); "; /// A row in the "tip" table. @@ -456,3 +485,27 @@ impl TryFrom<&rusqlite::Row<'_>> for DbWalletTransaction { }) } } + +/// An outpoint we have seen before in payjoin transactions +#[derive(Clone, Debug, PartialEq)] +pub struct DbPayjoinOutpoint { + pub outpoint: bitcoin::OutPoint, + pub created_at: Option, +} + +impl TryFrom<&rusqlite::Row<'_>> for DbPayjoinOutpoint { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let outpoint: Vec = row.get(0)?; + let outpoint = bitcoin::OutPoint::consensus_decode(&mut Cursor::new(outpoint)) + .expect("Outpoint should be decodable"); + + let created_at = row.get(1)?; + + Ok(DbPayjoinOutpoint { + outpoint, + created_at, + }) + } +} diff --git a/lianad/src/jsonrpc/api.rs b/lianad/src/jsonrpc/api.rs index 7f6082e6d..6fcb8231b 100644 --- a/lianad/src/jsonrpc/api.rs +++ b/lianad/src/jsonrpc/api.rs @@ -486,6 +486,39 @@ fn get_labels_bip329(control: &DaemonControl, params: Params) -> Result Result { + let res = control.receive_payjoin()?; + Ok(serde_json::json!(&res)) +} + +fn get_payjoin_info(control: &DaemonControl, params: Params) -> Result { + let txid = params + .get(0, "txid") + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))? + .as_str() + .ok_or_else(|| Error::invalid_params("Invalid 'txid' parameter."))?; + let txid = bitcoin::Txid::from_str(txid) + .map_err(|_| Error::invalid_params("Invalid 'txid' parameter."))?; + let res = control.get_payjoin_info(&txid)?; + Ok(serde_json::json!(&res)) +} + +fn get_active_payjoin_sessions(control: &DaemonControl) -> Result { + let res = control.get_active_payjoin_sessions()?; + Ok(serde_json::json!(&res)) +} + +fn get_payjoin_bip21(control: &DaemonControl, params: Params) -> Result { + let derivation_index = params + .get(0, "derivation_index") + .ok_or_else(|| Error::invalid_params("Missing 'derivation_index' parameter."))? + .as_u64() + .ok_or_else(|| Error::invalid_params("Invalid 'derivation_index' parameter."))? + as u32; + let res = control.get_payjoin_bip21(derivation_index)?; + Ok(serde_json::json!(&res)) +} + /// Handle an incoming JSONRPC2 request. pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result { let result = match req.method.as_str() { @@ -593,6 +626,20 @@ pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result receive_payjoin(control)?, + "getpayjoininfo" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'txid' parameter."))?; + get_payjoin_info(control, params)? + } + "getactivepayjoinsessions" => get_active_payjoin_sessions(control)?, + "getpayjoinbip21" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'derivation_index' parameter."))?; + get_payjoin_bip21(control, params)? + } _ => { return Err(Error::method_not_found()); } diff --git a/lianad/src/jsonrpc/rpc.rs b/lianad/src/jsonrpc/rpc.rs index 90d307db6..56ea91655 100644 --- a/lianad/src/jsonrpc/rpc.rs +++ b/lianad/src/jsonrpc/rpc.rs @@ -50,6 +50,8 @@ pub struct Request { /// A failure to broadcast a transaction to the P2P network. const BROADCAST_ERROR: i64 = 1_000; +const REPLAY_ERROR: i64 = 1_001; +const INTO_URL_ERROR: i64 = 1_002; /// JSONRPC2 error codes. See https://www.jsonrpc.org/specification#error_object. #[derive(Debug, PartialEq, Eq, Clone)] @@ -164,7 +166,8 @@ impl From for Error { | commands::CommandError::RbfError(..) | commands::CommandError::EmptyFilterList | commands::CommandError::RecoveryNotAvailable - | commands::CommandError::OutpointNotRecoverable(..) => { + | commands::CommandError::OutpointNotRecoverable(..) + | commands::CommandError::FailedToFetchOhttpKeys(..) => { Error::new(ErrorCode::InvalidParams, e.to_string()) } commands::CommandError::RescanTrigger(..) => { @@ -173,6 +176,15 @@ impl From for Error { commands::CommandError::TxBroadcast(_) => { Error::new(ErrorCode::ServerError(BROADCAST_ERROR), e.to_string()) } + commands::CommandError::FailedToPostOriginalPayjoinProposal(_) => { + Error::new(ErrorCode::ServerError(BROADCAST_ERROR), e.to_string()) + } + commands::CommandError::ReplayError(_) => { + Error::new(ErrorCode::ServerError(REPLAY_ERROR), e.to_string()) + } + commands::CommandError::IntoUrlError(_) => { + Error::new(ErrorCode::ServerError(INTO_URL_ERROR), e.to_string()) + } } } } diff --git a/lianad/src/lib.rs b/lianad/src/lib.rs index 86e339c72..bb20cbe83 100644 --- a/lianad/src/lib.rs +++ b/lianad/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; mod database; pub mod datadir; mod jsonrpc; +pub mod payjoin; #[cfg(test)] mod testutils; diff --git a/lianad/src/payjoin/db.rs b/lianad/src/payjoin/db.rs new file mode 100644 index 000000000..3cf93be4d --- /dev/null +++ b/lianad/src/payjoin/db.rs @@ -0,0 +1,85 @@ +use payjoin::persist::SessionPersister; +use payjoin::receive::v2::SessionEvent as ReceiverSessionEvent; +use serde::{Deserialize, Serialize}; +use std::fmt::{self, Display, Formatter}; +use std::sync::Arc; + +use crate::database::DatabaseInterface; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionId(pub i64); + +impl SessionId { + pub fn new(id: i64) -> Self { + Self(id) + } +} + +#[derive(Debug)] +pub(crate) enum PersisterError { + Serialize(serde_json::Error), +} + +impl Display for PersisterError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + PersisterError::Serialize(e) => write!(f, "Serialization failed: {e}"), + } + } +} + +impl std::error::Error for PersisterError {} + +#[derive(Clone)] +pub(crate) struct ReceiverPersister { + db: Arc, + pub session_id: SessionId, +} + +impl ReceiverPersister { + pub fn new(db: Arc, derivation_index: u32, bip21: &str) -> Self { + let mut db_conn = db.connection(); + let session_id = db_conn.save_new_payjoin_receiver_session(derivation_index, bip21); + Self { + db, + session_id: SessionId(session_id), + } + } + + pub fn from_id(db: Arc, id: SessionId) -> Self { + Self { db, session_id: id } + } +} + +impl SessionPersister for ReceiverPersister { + type SessionEvent = ReceiverSessionEvent; + type InternalStorageError = PersisterError; + + fn save_event( + &self, + event: Self::SessionEvent, + ) -> std::result::Result<(), Self::InternalStorageError> { + let mut db_conn = self.db.connection(); + let event_ser = serde_json::to_vec(&event).map_err(PersisterError::Serialize)?; + db_conn.save_receiver_session_event(&self.session_id, event_ser); + Ok(()) + } + + fn load( + &self, + ) -> std::result::Result>, Self::InternalStorageError> + { + let mut db_conn = self.db.connection(); + let events = db_conn.load_receiver_session_events(&self.session_id); + let iter = events + .into_iter() + .map(|event| serde_json::from_slice(&event).expect("Event to be serialized correctly")); + Ok(Box::new(iter)) + } + + fn close(&self) -> std::result::Result<(), Self::InternalStorageError> { + let mut db_conn = self.db.connection(); + db_conn.update_receiver_session_completed_at(&self.session_id); + Ok(()) + } +} diff --git a/lianad/src/payjoin/helpers.rs b/lianad/src/payjoin/helpers.rs new file mode 100644 index 000000000..dd31c76f2 --- /dev/null +++ b/lianad/src/payjoin/helpers.rs @@ -0,0 +1,130 @@ +use std::time::Duration; + +use miniscript::{ + bitcoin::{secp256k1, Psbt, ScriptBuf, TxOut}, + psbt::PsbtExt, +}; + +use payjoin::{bitcoin::Amount, IntoUrl, OhttpKeys}; +use reqwest::{header::ACCEPT, Proxy}; + +pub(crate) const OHTTP_RELAY: &str = "https://pj.bobspacebkk.com"; +pub(crate) const PAYJOIN_DIRECTORY: &str = "https://payjo.in"; + +pub(crate) fn http_agent() -> reqwest::blocking::Client { + reqwest::blocking::Client::new() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FetchOhttpKeysError { + Reqwest(String), + InvalidOhttpKeys(String), + InvalidUrl(String), + UrlParseError, + UnexpectedStatusCode(reqwest::StatusCode), +} + +impl std::error::Error for FetchOhttpKeysError {} +impl std::fmt::Display for FetchOhttpKeysError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +pub(crate) fn fetch_ohttp_keys( + ohttp_relay: impl IntoUrl, + payjoin_directory: impl IntoUrl, +) -> Result { + let payjoin_directory_str = payjoin_directory.as_str().to_string(); + let payjoin_directory_url = payjoin_directory + .into_url() + .map_err(|_| FetchOhttpKeysError::InvalidUrl(payjoin_directory_str.clone()))? + .join("/.well-known/ohttp-gateway") + .map_err(|_| FetchOhttpKeysError::UrlParseError)?; + + let ohttp_relay_str = ohttp_relay.as_str().to_string(); + let proxy = Proxy::all( + ohttp_relay + .into_url() + .map_err(|_| FetchOhttpKeysError::InvalidUrl(ohttp_relay_str.clone()))? + .as_str(), + ) + .map_err(|e| FetchOhttpKeysError::Reqwest(e.to_string()))?; + let client = reqwest::blocking::Client::builder() + .proxy(proxy) + .build() + .map_err(|e| FetchOhttpKeysError::Reqwest(e.to_string()))?; + let res = client + .get(payjoin_directory_url) + .header(ACCEPT, "application/ohttp-keys") + .send() + .map_err(|e| FetchOhttpKeysError::Reqwest(e.to_string()))?; + validate_ohttp_keys_response(res) +} + +fn validate_ohttp_keys_response( + res: reqwest::blocking::Response, +) -> Result { + if !res.status().is_success() { + return Err(FetchOhttpKeysError::UnexpectedStatusCode(res.status())); + } + + let body = res.bytes().unwrap().to_vec(); + match OhttpKeys::decode(&body) { + Ok(ohttp_keys) => Ok(ohttp_keys), + Err(err) => Err(FetchOhttpKeysError::InvalidOhttpKeys(err.to_string())), + } +} + +pub(crate) fn post_request( + req: payjoin::Request, +) -> Result { + let http = http_agent(); + http.post(req.url) + .header("Content-Type", req.content_type) + .body(req.body) + .timeout(Duration::from_secs(10)) + .send() +} + +/// Optimistically attempt to create witness for all inputs. +/// This method will not fail even if some inputs are not finalized or include invalid partial signatures. +pub(crate) fn finalize_psbt(psbt: &mut Psbt, secp: &secp256k1::Secp256k1) { + let mut witness_utxo_to_clean = vec![]; + let mut inputs_to_finalize = vec![]; + for (index, input) in psbt.inputs.iter_mut().enumerate() { + if input.witness_utxo.is_none() { + // Sender's wallet cleans this up (from original PSBT) but we need it to finalize_inp_mut() below + input.witness_utxo = Some(TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::default(), + }); + + input.final_script_sig = None; + input.final_script_witness = None; + + witness_utxo_to_clean.push(index); + continue; + } + if input.final_script_sig.is_some() + || input.final_script_witness.is_some() + || input.partial_sigs.is_empty() + { + input.final_script_sig = None; + input.final_script_witness = None; + continue; + } + inputs_to_finalize.push(index); + } + + for index in &inputs_to_finalize { + match psbt.finalize_inp_mut(secp, *index) { + Ok(_) => log::info!("Finalizing input at: {}", index), + Err(e) => log::warn!("Failed to finalize input at: {} | {}", index, e), + } + } + + for index in witness_utxo_to_clean { + psbt.inputs[index].witness_utxo = None; + } +} diff --git a/lianad/src/payjoin/mod.rs b/lianad/src/payjoin/mod.rs new file mode 100644 index 000000000..41224358a --- /dev/null +++ b/lianad/src/payjoin/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod db; +pub(crate) mod helpers; +pub(crate) mod receiver; +pub mod types; diff --git a/lianad/src/payjoin/receiver.rs b/lianad/src/payjoin/receiver.rs new file mode 100644 index 000000000..e27af2315 --- /dev/null +++ b/lianad/src/payjoin/receiver.rs @@ -0,0 +1,392 @@ +use std::{ + collections::HashMap, + error::Error, + sync::{self, Arc}, +}; + +use liana::{descriptors, spend::AddrInfo}; + +use payjoin::{ + bitcoin::{ + self, consensus::encode::serialize_hex, psbt::Input, secp256k1, OutPoint, Sequence, TxIn, + Weight, + }, + persist::{OptionalTransitionOutcome, SessionPersister}, + receive::{ + v2::{ + replay_event_log, Initialized, MaybeInputsOwned, MaybeInputsSeen, OutputsUnknown, + PayjoinProposal, ProvisionalProposal, ReceiveSession, Receiver, + UncheckedOriginalPayload, WantsFeeRange, WantsInputs, WantsOutputs, + }, + InputPair, + }, + ImplementationError, +}; + +use crate::{ + bitcoin::BitcoinInterface, + database::{Coin, CoinStatus, DatabaseConnection, DatabaseInterface}, + payjoin::helpers::{finalize_psbt, post_request, OHTTP_RELAY}, +}; + +use super::db::ReceiverPersister; + +fn read_from_directory( + receiver: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let (req, context) = receiver + .create_poll_request(OHTTP_RELAY) + .map_err(|e| format!("Failed to extract request: {:?}", e))?; + + let proposal = match post_request(req.clone()) { + Ok(ohttp_response) => { + let response_bytes = ohttp_response.bytes()?; + let state_transition = receiver + .process_response(response_bytes.as_ref(), context) + .save(persister); + match state_transition { + Ok(OptionalTransitionOutcome::Progress(next_state)) => next_state, + Ok(OptionalTransitionOutcome::Stasis(_current_state)) => { + return Err("NoResults".into()) + } + Err(e) => return Err(e.into()), + } + } + Err(e) => return Err(Box::new(e)), + }; + check_proposal(proposal, persister, db_conn, bit, desc, secp) +} + +fn check_proposal( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + // Receive Check 1: Can Broadcast + let proposal = proposal + .check_broadcast_suitability(None, |tx| { + let result = bit.test_mempool_accept(vec![serialize_hex(tx)]); + match result.first().cloned() { + Some(can_broadcast) => Ok(can_broadcast), + None => Ok(false), + } + }) + .save(persister)?; + check_inputs_not_owned(proposal, persister, db_conn, desc, secp) +} + +fn check_inputs_not_owned( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal + .check_inputs_not_owned(&mut |script| { + let address = + bitcoin::Address::from_script(script, db_conn.network()).map_err(|e| { + ImplementationError::from(Box::new(e) as Box) + })?; + Ok(db_conn + .derivation_index_by_address(&address) + .map(|(index, is_change)| AddrInfo { index, is_change }) + .is_some()) + }) + .save(persister)?; + check_no_inputs_seen_before(proposal, persister, db_conn, desc, secp) +} + +fn check_no_inputs_seen_before( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal + .check_no_inputs_seen_before(&mut |outpoint| { + let seen = db_conn.insert_input_seen_before(&[*outpoint]); + Ok(seen) + }) + .save(persister)?; + identify_receiver_outputs(proposal, persister, db_conn, desc, secp) +} + +fn identify_receiver_outputs( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + log::debug!("[Payjoin] receiver outputs"); + let proposal = proposal + .identify_receiver_outputs(&mut |script| { + let address = + bitcoin::Address::from_script(script, db_conn.network()).map_err(|e| { + ImplementationError::from(Box::new(e) as Box) + })?; + Ok(db_conn + .derivation_index_by_address(&address) + .map(|(index, is_change)| AddrInfo { index, is_change }) + .is_some()) + }) + .save(persister)?; + commit_outputs(proposal, persister, db_conn, desc, secp) +} + +fn commit_outputs( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal.commit_outputs().save(persister)?; + contribute_inputs(proposal, persister, db_conn, desc, secp) +} + +fn contribute_inputs( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let coins = db_conn.coins(&[CoinStatus::Confirmed], &[]); + + let mut candidate_inputs_map = HashMap::::new(); + for (outpoint, coin) in coins.iter() { + let txs = db_conn.list_wallet_transactions(&[outpoint.txid]); + let (db_tx, _, _) = txs + .first() + .expect("There should be at least tx in the wallet"); + + let tx = db_tx.clone(); + + let txout = tx.tx_out(outpoint.vout as usize)?.clone(); + + let derived_desc = if coin.is_change { + desc.change_descriptor().derive(coin.derivation_index, secp) + } else { + desc.receive_descriptor() + .derive(coin.derivation_index, secp) + }; + + let txin = TxIn { + previous_output: *outpoint, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + ..Default::default() + }; + + let mut psbtin = Input { + non_witness_utxo: Some(tx.clone()), + witness_utxo: Some(txout.clone()), + ..Default::default() + }; + + derived_desc.update_psbt_in(&mut psbtin); + // TODO: revisit using primary path boolean. Perphaps we should use both paths and take the max. + let worse_case_weight = Weight::from_wu_usize(desc.max_sat_weight(true)) + // Segwit marker + + Weight::from_wu(2) + // Non-witness data size + + Weight::from_non_witness_data_size(txin.base_size() as u64); + + candidate_inputs_map.insert(*outpoint, (*coin, txin, psbtin, worse_case_weight)); + } + + let mut candidate_inputs = candidate_inputs_map + .values() + .map(|(_, txin, psbtin, weight)| { + InputPair::new(txin.clone(), psbtin.clone(), Some(*weight)).unwrap() + }); + log::info!("[Payjoin] Candidate inputs: {:?}", candidate_inputs); + + if candidate_inputs.len() == 0 { + return Err("No candidate inputs".into()); + } + + let selected_input = proposal + .try_preserving_privacy(candidate_inputs.clone()) + .unwrap_or( + candidate_inputs + .next() + .expect("Should have at least one input") + .clone(), + ); + + let proposal = proposal + .contribute_inputs(vec![selected_input])? + .commit_inputs() + .save(persister)?; + + apply_fee_range(proposal, persister, db_conn, secp)?; + Ok(()) +} + +fn apply_fee_range( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let proposal = proposal.apply_fee_range(None, None).save(persister)?; + let psbt = proposal.psbt_to_sign(); + + db_conn.store_spend(&psbt); + log::info!("[Payjoin] PSBT in the DB..."); + + finalize_proposal(proposal, persister, db_conn, secp)?; + Ok(()) +} + +fn finalize_proposal( + proposal: Receiver, + persister: &ReceiverPersister, + db_conn: &mut Box, + secp: &secp256k1::Secp256k1, +) -> Result<(), Box> { + let psbt = proposal.psbt_to_sign(); + + let txid = psbt.unsigned_tx.compute_txid(); + if let Some(psbt) = db_conn.spend_tx(&txid) { + let mut is_signed = false; + for psbtin in &psbt.inputs { + if !psbtin.partial_sigs.is_empty() { + log::debug!("[Payjoin] PSBT is signed!"); + is_signed = true; + break; + } + } + + if is_signed { + let proposal = proposal + .finalize_proposal(|_| { + let mut psbt = psbt.clone(); + finalize_psbt(&mut psbt, secp); + Ok(psbt) + }) + .save(persister)?; + + send_payjoin_proposal(proposal, persister)?; + } + } + Ok(()) +} + +fn send_payjoin_proposal( + proposal: Receiver, + persister: &ReceiverPersister, +) -> Result<(), Box> { + let (req, ctx) = proposal + .create_post_request(OHTTP_RELAY) + .expect("Failed to extract request"); + + // Respond to sender + log::info!("[Payjoin] Receiver responding to sender..."); + match post_request(req) { + Ok(resp) => { + proposal + .process_response(resp.bytes().expect("Failed to read response").as_ref(), ctx) + .save(persister)?; + } + Err(err) => log::error!("[Payjoin] send_payjoin_proposal(): {}", err), + } + Ok(()) +} + +fn process_receiver_session( + db_conn: &mut Box, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, + persister: ReceiverPersister, +) -> Result<(), Box> { + let (state, _) = replay_event_log(&persister) + .map_err(|e| format!("Failed to replay receiver event log: {:?}", e))?; + + match state { + ReceiveSession::Initialized(context) => { + read_from_directory(context, &persister, db_conn, bit, desc, secp)?; + } + ReceiveSession::UncheckedOriginalPayload(proposal) => { + check_proposal(proposal, &persister, db_conn, bit, desc, secp)?; + } + ReceiveSession::MaybeInputsOwned(proposal) => { + check_inputs_not_owned(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::MaybeInputsSeen(proposal) => { + check_no_inputs_seen_before(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::OutputsUnknown(proposal) => { + identify_receiver_outputs(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::WantsOutputs(proposal) => { + commit_outputs(proposal, &persister, db_conn, desc, secp)?; + } + ReceiveSession::WantsInputs(proposal) => { + contribute_inputs(proposal, &persister, db_conn, desc, secp)? + } + ReceiveSession::WantsFeeRange(proposal) => { + apply_fee_range(proposal, &persister, db_conn, secp)?; + } + ReceiveSession::ProvisionalProposal(proposal) => { + finalize_proposal(proposal, &persister, db_conn, secp)? + } + ReceiveSession::PayjoinProposal(proposal) => send_payjoin_proposal(proposal, &persister)?, + ReceiveSession::Closed(_) | ReceiveSession::HasReplyableError(_) => { + log::info!("Payjoin session completed or expired, marking as closed"); + persister.close()?; + } + ReceiveSession::Monitor(_) => { + log::debug!("Payjoin session in monitoring state"); + } + } + Ok(()) +} + +pub(crate) fn payjoin_receiver_check( + db: &sync::Arc>, + bit: &mut sync::Arc>, + desc: &descriptors::LianaDescriptor, + secp: &secp256k1::Secp256k1, +) { + let mut db_conn = db.connection(); + + for session_id in db_conn.get_all_active_receiver_session_ids() { + let persister = ReceiverPersister::from_id(Arc::new(db.clone()), session_id.clone()); + + match replay_event_log(&persister) { + Ok(_) => match process_receiver_session(&mut db_conn, bit, desc, secp, persister) { + Ok(_) => (), + Err(e) => { + log::warn!("process_receiver_session(): {}", e); + } + }, + Err(e) => { + let error_str = e.to_string(); + if error_str.contains("expired") { + log::info!( + "Payjoin session {:?} expired, marking as closed", + session_id + ); + if let Err(close_err) = persister.close() { + log::warn!("Failed to close expired payjoin session: {}", close_err); + } + continue; + } + log::warn!("Failed to replay payjoin session {:?}: {}", session_id, e); + } + } + } +} diff --git a/lianad/src/payjoin/types.rs b/lianad/src/payjoin/types.rs new file mode 100644 index 000000000..37404d270 --- /dev/null +++ b/lianad/src/payjoin/types.rs @@ -0,0 +1,40 @@ +use payjoin::{receive::v2::ReceiveSession, receive::v2::SessionOutcome as ReceiveSessionOutcome}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum PayjoinStatus { + Pending, + WaitingToSign, + Success, + Failed, + Unknown, +} + +impl From for PayjoinStatus { + fn from(session: ReceiveSession) -> Self { + match session { + ReceiveSession::Initialized(_) + | ReceiveSession::UncheckedOriginalPayload(_) + | ReceiveSession::MaybeInputsOwned(_) + | ReceiveSession::MaybeInputsSeen(_) + | ReceiveSession::OutputsUnknown(_) + | ReceiveSession::WantsOutputs(_) + | ReceiveSession::WantsInputs(_) + | ReceiveSession::WantsFeeRange(_) => PayjoinStatus::Pending, + ReceiveSession::ProvisionalProposal(_) => PayjoinStatus::WaitingToSign, + ReceiveSession::PayjoinProposal(_) => PayjoinStatus::Success, + ReceiveSession::HasReplyableError(_) => PayjoinStatus::Failed, + ReceiveSession::Closed(outcome) => match outcome { + ReceiveSessionOutcome::Success(_) => PayjoinStatus::Success, + _ => PayjoinStatus::Failed, + }, + ReceiveSession::Monitor(_) => PayjoinStatus::Unknown, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PayjoinInfo { + pub status: PayjoinStatus, + pub bip21: String, +} diff --git a/lianad/src/testutils.rs b/lianad/src/testutils.rs index da03225a5..64e895cd0 100644 --- a/lianad/src/testutils.rs +++ b/lianad/src/testutils.rs @@ -143,6 +143,10 @@ impl BitcoinInterface for DummyBitcoind { fn mempool_entry(&self, _: &bitcoin::Txid) -> Option { None } + + fn test_mempool_accept(&self, _rawtxs: Vec) -> Vec { + todo!() + } } struct DummyDbState { @@ -550,6 +554,67 @@ impl DatabaseConnection for DummyDatabase { fn get_labels_bip329(&mut self, _offset: u32, _limit: u32) -> bip329::Labels { todo!() } + + fn payjoin_get_ohttp_keys(&mut self, _ohttp_relay: &str) -> Option<(u32, payjoin::OhttpKeys)> { + todo!() + } + + fn payjoin_save_ohttp_keys(&mut self, _ohttp_relay: &str, _ohttp_keys: payjoin::OhttpKeys) { + todo!() + } + + fn insert_input_seen_before(&mut self, _outpoints: &[bitcoin::OutPoint]) -> bool { + todo!() + } + + fn get_active_payjoin_sessions(&mut self) -> Vec<(crate::payjoin::db::SessionId, u32)> { + todo!() + } + + fn save_receiver_session_event( + &mut self, + _session_id: &crate::payjoin::db::SessionId, + _event: Vec, + ) { + todo!() + } + + fn load_receiver_session_events( + &mut self, + _session_id: &crate::payjoin::db::SessionId, + ) -> Vec> { + todo!() + } + + fn save_new_payjoin_receiver_session(&mut self, _derivation_index: u32, _bip21: &str) -> i64 { + todo!() + } + + fn get_payjoin_receiver_bip21(&mut self, _derivation_index: u32) -> Option { + todo!() + } + + fn update_payjoin_receiver_bip21(&mut self, _derivation_index: u32, _bip21: &str) { + todo!() + } + + fn get_all_active_receiver_session_ids(&mut self) -> Vec { + todo!() + } + + fn update_receiver_session_completed_at( + &mut self, + _session_id: &crate::payjoin::db::SessionId, + ) { + todo!() + } + + fn get_payjoin_receiver_session_id_from_txid( + &mut self, + _txid: &bitcoin::Txid, + ) -> Option { + todo!() + } } pub struct DummyLiana { From 0c223f7c41fa903297f6d473cedd5a6f84beafc0 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Fri, 30 Jan 2026 16:40:32 -0500 Subject: [PATCH 03/10] Minor adjustments in liana source --- liana/src/signer.rs | 4 +++- liana/src/spend.rs | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/liana/src/signer.rs b/liana/src/signer.rs index be7f671b0..94b2a80f7 100644 --- a/liana/src/signer.rs +++ b/liana/src/signer.rs @@ -307,7 +307,9 @@ impl HotSigner { if keypair.x_only_public_key().0 != *int_key { return Err(SignerError::InsanePsbt); } - let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner(); + let keypair = keypair + .tap_tweak(secp, psbt_in.tap_merkle_root) + .to_keypair(); let sighash = sighash_cache .taproot_key_spend_signature_hash(input_index, &prevouts, sighash_type) .map_err(|_| SignerError::InsanePsbt)?; diff --git a/liana/src/spend.rs b/liana/src/spend.rs index 4cbe42417..240ecb2be 100644 --- a/liana/src/spend.rs +++ b/liana/src/spend.rs @@ -58,6 +58,8 @@ pub enum SpendCreationError { SanityCheckFailure(Psbt), FetchingTransaction(bitcoin::OutPoint), CoinSelection(InsufficientFunds), + //TODO: wrap a more specific error + InvalidBip21, } impl fmt::Display for SpendCreationError { @@ -87,6 +89,7 @@ impl fmt::Display for SpendCreationError { "BUG! Please report this. Failed sanity checks for PSBT '{}'.", psbt ), + Self::InvalidBip21 => write!(f, "Invalid BIP21"), } } } From f1f7cdab569e0ecfb02949994e187fa5077467f9 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Fri, 30 Jan 2026 16:43:37 -0500 Subject: [PATCH 04/10] Add payjoin styles in ui framework --- Cargo.lock | 772 +++++++++++++++++++++++++++++--- liana-ui/src/color.rs | 6 + liana-ui/src/component/badge.rs | 4 + liana-ui/src/theme/palette.rs | 3 + liana-ui/src/theme/text.rs | 6 + 5 files changed, 727 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e683d944..f477c19dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", + "rand_core 0.6.4", +] + [[package]] name = "aead" version = "0.5.2" @@ -43,6 +53,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.2.17", + "opaque-debug", +] + [[package]] name = "aes" version = "0.8.4" @@ -50,8 +72,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc3be92e19a7ef47457b8e6f90707e12b6ac5d20c6f3866584fa3be0787d839f" +dependencies = [ + "aead 0.4.3", + "aes 0.7.5", + "cipher 0.3.0", + "ctr 0.7.0", + "ghash 0.4.4", + "subtle", ] [[package]] @@ -60,11 +96,11 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] @@ -298,7 +334,7 @@ dependencies = [ "ledger-transport-hidapi", "ledger_bitcoin_client", "regex", - "reqwest", + "reqwest 0.11.27", "serde", "serde_bytes", "serde_cbor", @@ -523,6 +559,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +[[package]] +name = "bhttp" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16fc24bc615b9fd63148f59b218ea58a444b55762f8845da910e23aca686398b" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "bip329" version = "0.3.0" @@ -596,9 +641,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.5" +version = "0.32.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" dependencies = [ "base58ck", "base64 0.21.7", @@ -607,7 +652,7 @@ dependencies = [ "bitcoin-io", "bitcoin-units", "bitcoin_hashes 0.14.0", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "hex_lit", "secp256k1", "serde", @@ -619,12 +664,31 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4e7cfd72b4e1eac651e2908660e90e65b729052bfd5d4004395a402c3e655cc" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "miniscript", "num_enum", "rand 0.9.2", ] +[[package]] +name = "bitcoin-hpke" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d37a54c486727c1d1ae9cc28dcf78b6e6ba20dcb88e8c892f1437d9ce215dc8c" +dependencies = [ + "aead 0.5.2", + "chacha20poly1305 0.10.1", + "digest 0.10.7", + "generic-array", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand_core 0.6.4", + "secp256k1", + "sha2 0.10.8", + "subtle", + "zeroize", +] + [[package]] name = "bitcoin-internals" version = "0.2.0" @@ -646,6 +710,29 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +[[package]] +name = "bitcoin-ohttp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a803a4b54e44635206b53329c78c0029d0c70926288ac2f07f4bb1267546cb" +dependencies = [ + "aead 0.4.3", + "aes-gcm 0.9.2", + "bitcoin-hpke", + "byteorder", + "chacha20poly1305 0.8.0", + "hex", + "hkdf 0.11.0", + "lazy_static", + "log", + "rand 0.8.5", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror 1.0.69", + "toml 0.5.11", +] + [[package]] name = "bitcoin-private" version = "0.1.0" @@ -688,10 +775,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "serde", ] +[[package]] +name = "bitcoin_uri" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0a228e083d1702f83389b0ac71eb70078dc8d7fcbb6cde864d1cbca145f5cc" +dependencies = [ + "bitcoin", + "percent-encoding-rfc3986", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -710,7 +807,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -719,6 +816,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -773,7 +879,7 @@ dependencies = [ "liana-gui", "liana-ui", "miniscript", - "reqwest", + "reqwest 0.11.27", "rfd", "rustls 0.23.36", "serde", @@ -920,6 +1026,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7ad89dc1128635074c268ee661f90c3f7e83d9fd12910608c36b47d6c3412" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.1.5", + "zeroize", +] + [[package]] name = "chacha20" version = "0.9.1" @@ -927,8 +1045,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580317203210c517b6d44794abfbe600698276db18127e37ad3e69bf5e848e5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.7.1", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", ] [[package]] @@ -937,10 +1068,10 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] @@ -958,6 +1089,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1024,10 +1164,10 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aaaf3f7409edc40001c30a4c1337f21558a8ceba2a4afe807da841a38ce83d6" dependencies = [ - "aes", + "aes 0.8.4", "base58", "bitcoin_hashes 0.13.0", - "ctr", + "ctr 0.9.2", "hidapi", "k256", "rand 0.8.5", @@ -1212,6 +1352,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1311,22 +1460,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "ctor-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" +[[package]] +name = "ctr" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a232f92a03f37dd7d7dd2adc67166c77e9cd88de5b019b9a9eecfaeaf7bfd481" +dependencies = [ + "cipher 0.3.0", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1342,7 +1511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -1405,13 +1574,22 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", @@ -1537,7 +1715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1575,7 +1753,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -2047,8 +2225,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2058,11 +2238,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.13.3+wasi-0.2.2", + "wasm-bindgen", "windows-targets 0.52.6", ] +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval 0.5.3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -2070,7 +2262,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", ] [[package]] @@ -2313,9 +2505,9 @@ checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" [[package]] name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] @@ -2345,13 +2537,42 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "hkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" +dependencies = [ + "digest 0.9.0", + "hmac 0.11.0", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2395,6 +2616,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.0" @@ -2419,18 +2663,39 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.8", "tokio", "tower-service", "tracing", "want", ] +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -2439,10 +2704,50 @@ checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http 0.2.12", - "hyper", + "hyper 0.14.32", "rustls 0.21.12", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.5", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -2888,6 +3193,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -3012,7 +3327,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.8", "signature", ] @@ -3202,7 +3517,7 @@ dependencies = [ "libc", "log", "open", - "reqwest", + "reqwest 0.11.27", "rfd", "rust-ini", "serde", @@ -3242,6 +3557,8 @@ dependencies = [ "liana", "log", "miniscript", + "payjoin", + "reqwest 0.11.27", "rusqlite", "serde", "serde_json", @@ -3250,9 +3567,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libfuzzer-sys" @@ -3281,7 +3598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3372,6 +3689,12 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lyon" version = "1.0.1" @@ -3660,11 +3983,11 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c6159f60beb3bbbcdc266bc789bfc6c37fdad7d7ca7152d3e049ef5af633f0" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "blake2", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "noise-protocol", - "sha2", + "sha2 0.10.8", "x25519-dalek", "zeroize", ] @@ -4167,12 +4490,37 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "payjoin" +version = "1.0.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e20b76ae28f1420a918e8051681fc9669ed7273e542e515baa329be78c3255a" +dependencies = [ + "bhttp", + "bitcoin", + "bitcoin-hpke", + "bitcoin-ohttp", + "bitcoin_uri", + "http 1.4.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "tracing", + "url", +] + [[package]] name = "percent-encoding" 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.6.5" @@ -4324,15 +4672,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.4.0", +] + [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.4.0", ] [[package]] @@ -4342,9 +4713,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -4510,6 +4881,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls 0.23.36", + "socket2 0.5.8", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.1", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.5.8", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.38" @@ -4721,9 +5147,9 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", - "hyper", - "hyper-rustls", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", @@ -4736,10 +5162,10 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tokio-util", "tower-service", "url", @@ -4751,6 +5177,44 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.5", +] + [[package]] name = "resvg" version = "0.42.0" @@ -4773,7 +5237,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -4937,6 +5401,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -5055,6 +5520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.0", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -5131,14 +5597,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -5199,8 +5666,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -5210,8 +5690,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -5244,7 +5724,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5380,6 +5860,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "softbuffer" version = "0.4.6" @@ -5519,6 +6009,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -5739,7 +6238,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.5.8", "tokio-macros", "windows-sys 0.52.0", ] @@ -5765,6 +6264,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + [[package]] name = "tokio-serial" version = "5.4.5" @@ -5857,6 +6366,45 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.8.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -6066,6 +6614,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8326b2c654932e3e4f9196e69d08fdf7cfd718e1dc6f66b347e6024a0c961402" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -6656,6 +7214,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.45.0" @@ -6692,6 +7256,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -6731,13 +7304,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -6756,6 +7346,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6774,6 +7370,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6792,12 +7394,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6816,6 +7430,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6834,6 +7454,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6852,6 +7478,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6870,6 +7502,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winit" version = "0.30.8" @@ -7264,6 +7902,12 @@ dependencies = [ "flate2", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zune-inflate" version = "0.2.54" diff --git a/liana-ui/src/color.rs b/liana-ui/src/color.rs index a2c293f77..cb2089b74 100644 --- a/liana-ui/src/color.rs +++ b/liana-ui/src/color.rs @@ -68,6 +68,12 @@ pub const BLUE: Color = Color::from_rgb( 0xFC as f32 / 255.0, ); +pub const PAYJOIN_PINK: Color = Color::from_rgb( + 0xC7 as f32 / 255.0, + 0x15 as f32 / 255.0, + 0x85 as f32 / 255.0, +); + // ============================================================================= // BUSINESS THEME COLORS (Light Mode with Cyan-Blue accent) // ============================================================================= diff --git a/liana-ui/src/component/badge.rs b/liana-ui/src/component/badge.rs index 14668f286..987d1756d 100644 --- a/liana-ui/src/component/badge.rs +++ b/liana-ui/src/component/badge.rs @@ -71,6 +71,10 @@ pub fn spent<'a, T: 'a>() -> Container<'a, T> { ) } +pub fn payjoin<'a, T: 'a>() -> Container<'a, T> { + badge_pill(" Payjoin ", "This is a Payjoin address") +} + pub fn badge_pill<'a, T: 'a>(label: &'a str, tooltip: &'a str) -> Container<'a, T> { Container::new({ tooltip::Tooltip::new( diff --git a/liana-ui/src/theme/palette.rs b/liana-ui/src/theme/palette.rs index 2ba83599b..5ca43bc55 100644 --- a/liana-ui/src/theme/palette.rs +++ b/liana-ui/src/theme/palette.rs @@ -28,6 +28,7 @@ pub struct Text { pub success: iced::Color, pub error: iced::Color, pub accent: iced::Color, + pub payjoin: iced::Color, } #[derive(Debug, Copy, Clone, PartialEq)] @@ -206,6 +207,7 @@ impl std::default::Default for Palette { success: color::GREEN, error: color::RED, accent: color::BLUE, + payjoin: color::PAYJOIN_PINK, }, buttons: Buttons { primary: Button { @@ -622,6 +624,7 @@ impl Palette { success: color::DARK_GREEN, error: color::RED, accent: color::BUSINESS_BLUE_DARK, + payjoin: color::PAYJOIN_PINK, }, buttons: Buttons { primary: Button { diff --git a/liana-ui/src/theme/text.rs b/liana-ui/src/theme/text.rs index c21572796..892b4cb35 100644 --- a/liana-ui/src/theme/text.rs +++ b/liana-ui/src/theme/text.rs @@ -63,3 +63,9 @@ pub fn accent(theme: &Theme) -> Style { pub fn custom(color: iced::Color) -> Style { Style { color: Some(color) } } + +pub fn payjoin(theme: &Theme) -> Style { + Style { + color: Some(theme.colors.text.payjoin), + } +} From d7791b7d0c057d1f316c0dd4be214a31e0b73e9e Mon Sep 17 00:00:00 2001 From: Benalleng Date: Tue, 10 Feb 2026 10:03:06 -0500 Subject: [PATCH 05/10] WIP add payjoin to frontend --- liana-gui/Cargo.toml | 3 + liana-gui/src/app/message.rs | 3 + liana-gui/src/app/state/receive.rs | 137 ++++++++++++--- liana-gui/src/app/state/spend/step.rs | 23 ++- liana-gui/src/app/view/message.rs | 7 +- liana-gui/src/app/view/psbt.rs | 60 ++++++- liana-gui/src/app/view/receive.rs | 157 +++++++++++++----- liana-gui/src/daemon/client/mod.rs | 20 +++ liana-gui/src/daemon/embedded.rs | 41 ++++- liana-gui/src/daemon/mod.rs | 11 ++ liana-gui/src/daemon/model.rs | 8 +- liana-gui/src/installer/step/node/bitcoind.rs | 1 - liana-gui/src/installer/step/wallet_alias.rs | 1 - liana-gui/src/launcher.rs | 1 - liana-gui/src/node/bitcoind.rs | 1 - .../services/connect/client/backend/mod.rs | 22 +++ 16 files changed, 424 insertions(+), 72 deletions(-) diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 0ec782120..de2e69bd1 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -64,6 +64,9 @@ open = { workspace = true } encrypted_backup = { workspace = true } +# payjoin +payjoin = { version = "1.0.0-rc.1", features = ["v2", "io"] } + [target.'cfg(windows)'.dependencies] zip = { workspace = true, default-features = false, features = ["bzip2", "deflate"] } diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 0568bf504..3e4ddd02d 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -67,6 +67,9 @@ pub enum Message { BroadcastModal(Result, Error>), RbfModal(Box, bool, Result, Error>), Export(ImportExportMessage), + ReceivePayjoin(Result<(Address, ChildNumber, Option), Error>), + PayjoinInitiated(Result), + ActivePayjoinSessions(Result, Error>), } impl From for Message { diff --git a/liana-gui/src/app/state/receive.rs b/liana-gui/src/app/state/receive.rs index 4df5012f7..6abd59f9c 100644 --- a/liana-gui/src/app/state/receive.rs +++ b/liana-gui/src/app/state/receive.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::str::FromStr; use std::sync::Arc; use iced::{widget::qr_code, Subscription, Task}; @@ -41,12 +42,21 @@ pub struct Addresses { list: Vec
, derivation_indexes: Vec, labels: HashMap, + bip21: HashMap, } impl Addresses { pub fn is_empty(&self) -> bool { self.list.is_empty() && self.derivation_indexes.is_empty() && self.labels.is_empty() } + + pub fn push(&mut self, address: Address, derivation_index: ChildNumber, bip21: Option) { + self.list.push(address); + self.derivation_indexes.push(derivation_index); + if let Some(b) = bip21 { + self.bip21.insert(derivation_index, b); + } + } } impl Labelled for Addresses { @@ -73,6 +83,7 @@ pub struct ReceivePanel { modal: Modal, warning: Option, processing: bool, + active_payjoin_sessions: HashSet, } impl ReceivePanel { @@ -89,6 +100,7 @@ impl ReceivePanel { modal: Modal::None, warning: None, processing: false, + active_payjoin_sessions: HashSet::new(), } } @@ -122,13 +134,18 @@ impl State for ReceivePanel { view::receive::receive( &self.addresses.list, &self.addresses.labels, + &self.addresses.derivation_indexes, + &self.addresses.bip21, &self.prev_addresses.list, + &self.prev_addresses.derivation_indexes, &self.prev_addresses.labels, self.show_prev_addresses, &self.selected, self.labels_edited.cache(), + &self.active_payjoin_sessions, self.prev_continue_from.is_none(), self.processing, + !cache.coins().is_empty(), ), ); @@ -177,8 +194,19 @@ impl State for ReceivePanel { match res { Ok((address, derivation_index)) => { self.warning = None; - self.addresses.list.push(address); - self.addresses.derivation_indexes.push(derivation_index); + self.addresses.push(address, derivation_index, None); + } + Err(e) => self.warning = Some(e), + } + Task::none() + } + Message::ReceivePayjoin(res) => { + self.processing = false; + match res { + Ok((address, derivation_index, bip21)) => { + self.warning = None; + self.addresses + .push(address.clone(), derivation_index, bip21); } Err(e) => self.warning = Some(e), } @@ -215,6 +243,19 @@ impl State for ReceivePanel { Message::ReceiveAddress, ) } + Message::View(view::Message::ReceivePayjoin) => { + let daemon = daemon.clone(); + Task::perform( + async move { + daemon + .receive_payjoin() + .await + .map(|res| (res.address, res.derivation_index, res.bip21)) + .map_err(|e| e.into()) + }, + Message::ReceivePayjoin, + ) + } Message::View(view::Message::ToggleShowPreviousAddresses) => { self.show_prev_addresses = !self.show_prev_addresses; Task::none() @@ -243,8 +284,8 @@ impl State for ReceivePanel { if entry.index == 0.into() { continue; } - self.prev_addresses.list.push(entry.address.clone()); - self.prev_addresses.derivation_indexes.push(entry.index); + self.prev_addresses + .push(entry.address.clone(), entry.index, None); if let Some(label) = &entry.label { self.prev_addresses.labels.insert( LabelItem::from(entry.address.clone()).to_string(), @@ -261,6 +302,20 @@ impl State for ReceivePanel { }; Task::none() } + Message::ActivePayjoinSessions(res) => { + match res { + Ok(indexes) => { + self.active_payjoin_sessions = indexes + .into_iter() + .map(|i| ChildNumber::from_normal_idx(i).unwrap()) + .collect(); + } + Err(e) => { + log::error!("Failed to load active payjoin sessions: {:?}", e); + } + } + Task::none() + } Message::View(view::Message::Next) => { if self.prev_continue_from.is_some() { self.processing = true; @@ -286,9 +341,21 @@ impl State for ReceivePanel { Task::none() } } - Message::View(view::Message::ShowQrCode(i)) => { - if let (Some(address), Some(index)) = (self.address(i), self.derivation_index(i)) { - if let Some(modal) = ShowQrCodeModal::new(address, *index) { + Message::View(view::Message::ShowQrCode(i, bip21)) => { + if let Some(address) = self.address(i) { + if bip21.is_some() { + if let Some(modal) = + ShowQrCodeModal::new(&bip21.clone().unwrap_or(address.to_string())) + { + self.modal = Modal::ShowQrCode(modal); + } else { + tracing::error!( + "Failed to create QR modal for BIP21 '{:?}' (address {})", + bip21, + address + ); + } + } else if let Some(modal) = ShowQrCodeModal::new(&address.to_string()) { self.modal = Modal::ShowQrCode(modal); } } @@ -311,15 +378,28 @@ impl State for ReceivePanel { ) -> Task { let data_dir = self.data_dir.clone(); *self = Self::new(data_dir, wallet); - Task::perform( - async move { - daemon - .list_revealed_addresses(false, true, PREV_ADDRESSES_PAGE_SIZE, None) - .await - .map_err(|e| e.into()) - }, - |res| Message::RevealedAddresses(res, None), - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + Task::batch([ + Task::perform( + async move { + daemon1 + .list_revealed_addresses(false, true, PREV_ADDRESSES_PAGE_SIZE, None) + .await + .map_err(|e| e.into()) + }, + |res| Message::RevealedAddresses(res, None), + ), + Task::perform( + async move { + daemon2 + .get_active_payjoin_sessions() + .await + .map_err(|e| e.into()) + }, + Message::ActivePayjoinSessions, + ), + ]) } } @@ -420,13 +500,21 @@ pub struct ShowQrCodeModal { } impl ShowQrCodeModal { - pub fn new(address: &Address, index: ChildNumber) -> Option { - qr_code::Data::new(format!("bitcoin:{}?index={}", address, index)) - .ok() - .map(|qr_code| Self { + pub fn new(address: &str) -> Option { + if Address::from_str(address).is_ok() { + qr_code::Data::new(format!("bitcoin:{}", address)) + .ok() + .map(|qr_code| Self { + qr_code, + address: address.to_string(), + }) + } else { + // Already in bip21 format + qr_code::Data::new(address).ok().map(|qr_code| Self { qr_code, address: address.to_string(), }) + } } fn view(&self) -> Element { @@ -480,11 +568,18 @@ mod tests { continue_from: None, })), ), + ( + Some( + json!({"method": "getactivepayjoinsessions", "params": Option::::None}), + ), + Ok(json!(Vec::::new())), + ), ( Some(json!({"method": "getnewaddress", "params": Option::::None})), Ok(json!(GetAddressResult::new( addr.clone(), - ChildNumber::from_normal_idx(0).unwrap() + ChildNumber::from_normal_idx(0).unwrap(), + None ))), ), ]); diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 99a0e34e3..40a9836ce 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -1,7 +1,7 @@ use std::{ cmp::Ordering, collections::{HashMap, HashSet}, - convert::TryInto, + convert::{TryFrom, TryInto}, iter::FromIterator, str::FromStr, sync::Arc, @@ -17,9 +17,10 @@ use liana::{ }, spend::{SpendCreationError, DUST_OUTPUT_SATS, MAX_FEERATE}, }; -use lianad::commands::ListCoinsEntry; +use lianad::{commands::ListCoinsEntry, payjoin::types::PayjoinStatus}; use liana_ui::{component::form, widget::Element}; +use payjoin::Uri; use crate::{ app::{ @@ -1091,6 +1092,23 @@ impl SaveSpend { impl Step for SaveSpend { fn load(&mut self, _coins: &[Coin], _tip_height: i32, draft: &TransactionDraft) { let (psbt, warnings) = draft.generated.clone().unwrap(); + + let recipient = draft.recipients.first().expect("one recipient"); + let bip21 = format!( + "bitcoin:{}?amount={}", + recipient.address.value, recipient.amount.value + ); + + let payjoin_status = if let Ok(uri) = Uri::try_from(bip21.as_str()) { + if uri.assume_checked().extras.pj_is_supported() { + Some(PayjoinStatus::Pending) + } else { + None + } + } else { + None + }; + let mut tx = SpendTx::new( None, psbt, @@ -1098,6 +1116,7 @@ impl Step for SaveSpend { &self.wallet.main_descriptor, &self.curve, draft.network, + payjoin_status, ); tx.labels.clone_from(&draft.labels); diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index dd874bacf..daffd6d6e 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -22,6 +22,7 @@ pub enum Message { SelectPayment(OutPoint), Label(Vec, LabelMessage), NextReceiveAddress, + ReceivePayjoin, ToggleShowPreviousAddresses, SelectAddress(Address), Settings(SettingsMessage), @@ -32,12 +33,13 @@ pub enum Message { Previous, SelectHardwareWallet(usize), CreateRbf(CreateRbfMessage), - ShowQrCode(usize), + ShowQrCode(usize, Option), ImportExport(ImportExportMessage), HideRescanWarning, ExportPsbt, ImportPsbt, OpenUrl(String), + PayjoinInitiate, } impl Close for Message { @@ -66,6 +68,7 @@ pub enum CreateSpendMessage { Generate, SendMaxToRecipient(usize), Clear, + Bip21Edited(usize, String), } #[derive(Debug, Clone)] @@ -87,6 +90,8 @@ pub enum SpendTxMessage { EditPsbt, PsbtEdited(String), Next, + SendPayjoin, + PayjoinInitiated, } #[derive(Debug, Clone)] diff --git a/liana-gui/src/app/view/psbt.rs b/liana-gui/src/app/view/psbt.rs index b5ac60e17..2b326e1b6 100644 --- a/liana-gui/src/app/view/psbt.rs +++ b/liana-gui/src/app/view/psbt.rs @@ -25,6 +25,7 @@ use liana_ui::{ icon, theme, widget::*, }; +use lianad::payjoin::types::PayjoinStatus; use crate::{ app::{ @@ -333,6 +334,10 @@ pub fn spend_header<'a>( .into() } +pub fn payjoin_send_success_view<'a>() -> Element<'a, Message> { + card::simple(text("Payjoin sent successfully")).into() +} + pub fn spend_overview_view<'a>( tx: &'a SpendTx, desc_info: &'a LianaPolicy, @@ -427,6 +432,23 @@ pub fn spend_overview_view<'a>( .width(Length::Fixed(150.0)), ) }) + .push_maybe(if tx.path_ready().is_some() { + if let Some(payjoin_status) = &tx.payjoin_status { + if *payjoin_status == PayjoinStatus::Pending { + Some( + button::secondary(None, "Send Payjoin") + .on_press(Message::Spend(SpendTxMessage::SendPayjoin)) + .width(Length::Fixed(150.0)), + ) + } else { + None + } + } else { + None + } + } else { + None + }) .align_y(Alignment::Center) .spacing(20), ) @@ -442,7 +464,43 @@ pub fn signatures<'a>( keys_aliases: &'a HashMap, ) -> Element<'a, Message> { Column::new() - .push(if let Some(sigs) = tx.path_ready() { + .push(if tx.status == SpendStatus::PayjoinInitiated { + Container::new( + scrollable( + Row::new() + .spacing(5) + .align_y(Alignment::Center) + .spacing(10) + .push(p1_bold("Status")) + .push(icon::circle_check_icon().style(theme::text::payjoin)) + .push(text("Payjoin Initiated").bold().style(theme::text::payjoin)), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .padding(15) + } else if tx.status == SpendStatus::PayjoinProposalReady { + Container::new( + scrollable( + Row::new() + .spacing(5) + .align_y(Alignment::Center) + .spacing(10) + .push(p1_bold("Status")) + .push(icon::circle_check_icon().style(theme::text::payjoin)) + .push( + text("Payjoin Proposal Ready For Signing") + .bold() + .style(theme::text::payjoin), + ), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .padding(15) + } else if let Some(sigs) = tx.path_ready() { Container::new( scrollable( Row::new() diff --git a/liana-gui/src/app/view/receive.rs b/liana-gui/src/app/view/receive.rs index fd015901a..d15726f81 100644 --- a/liana-gui/src/app/view/receive.rs +++ b/liana-gui/src/app/view/receive.rs @@ -4,7 +4,7 @@ use iced::{ alignment::Horizontal, widget::{ qr_code::{self, QRCode}, - scrollable, Button, Space, + scrollable, tooltip, Button, Space, }, Alignment, Length, }; @@ -17,7 +17,7 @@ use liana::miniscript::bitcoin::{ use liana_ui::{ component::{ - button, card, form, + badge, button, card, form, text::{self, *}, }, icon, theme, @@ -37,8 +37,10 @@ use super::message::Message; fn address_card<'a>( row_index: usize, address: &'a bitcoin::Address, + maybe_bip21: Option, labels: &'a HashMap, labels_editing: &'a HashMap>, + is_payjoin: bool, ) -> Container<'a, Message> { let addr = address.to_string(); card::simple( @@ -55,7 +57,16 @@ fn address_card<'a>( scrollable( Column::new() .push(Space::with_height(Length::Fixed(10.0))) - .push(p2_regular(address).small().style(theme::text::secondary)) + .push( + p2_regular( + maybe_bip21 + .clone() + .map(|bip21| bip21.to_string()) + .unwrap_or(addr.clone()), + ) + .small() + .style(theme::text::secondary), + ) // Space between the address and the scrollbar .push(Space::with_height(Length::Fixed(10.0))), ) @@ -67,9 +78,19 @@ fn address_card<'a>( ) .width(Length::Fill), ) + .push(if is_payjoin { + badge::payjoin() + } else { + Container::new(p2_regular("").small()) + }) .push( Button::new(icon::clipboard_icon().style(theme::text::secondary)) - .on_press(Message::Clipboard(addr)) + .on_press(Message::Clipboard( + maybe_bip21 + .clone() + .map(|bip21| bip21.to_string()) + .unwrap_or(addr.clone()), + )) .style(theme::button::transparent_border), ) .align_y(Alignment::Center), @@ -83,7 +104,7 @@ fn address_card<'a>( .push(Space::with_width(Length::Fill)) .push( button::secondary(None, "Show QR Code") - .on_press(Message::ShowQrCode(row_index)), + .on_press(Message::ShowQrCode(row_index, maybe_bip21)), ), ) .spacing(10), @@ -94,13 +115,18 @@ fn address_card<'a>( pub fn receive<'a>( addresses: &'a [bitcoin::Address], labels: &'a HashMap, + derivation_indexes: &'a [liana::miniscript::bitcoin::bip32::ChildNumber], + bip21_map: &'a HashMap, prev_addresses: &'a [bitcoin::Address], + prev_derivation_indexes: &'a [liana::miniscript::bitcoin::bip32::ChildNumber], prev_labels: &'a HashMap, show_prev_addresses: bool, selected: &'a HashSet, labels_editing: &'a HashMap>, + active_payjoin_sessions: &'a HashSet, is_last_page: bool, processing: bool, + has_coins: bool, ) -> Element<'a, Message> { // Number of start and end address characters to show in collapsed view. const NUM_ADDR_CHARS: usize = 16; @@ -110,15 +136,31 @@ pub fn receive<'a>( Row::new() .align_y(Alignment::Center) .push(Container::new(h3("Receive")).width(Length::Fill)) - .push({ - let (icon, label) = (Some(icon::plus_icon()), "Generate address"); - if addresses.is_empty() { - button::primary(icon, label) - } else { - button::secondary(icon, label) - } - .on_press(Message::NextReceiveAddress) - }), + .push( + Row::new() + .spacing(10) + .push({ + let (icon, label) = (Some(icon::plus_icon()), "Generate address"); + if addresses.is_empty() { + button::primary(icon, label) + } else { + button::secondary(icon, label) + } + .on_press(Message::NextReceiveAddress) + }) + .push(if has_coins { + Element::::from( + button::secondary(Some(icon::plus_icon()), "Receive Payjoin") + .on_press(Message::ReceivePayjoin), + ) + } else { + Element::::from(Container::new(tooltip::Tooltip::new( + button::secondary(Some(icon::plus_icon()), "Receive Payjoin"), + "Account balance required to initiate payjoin", + tooltip::Position::Bottom, + ))) + }), + ), ) .push(text("Always generate a new address for each deposit.")) .push( @@ -129,7 +171,22 @@ pub fn receive<'a>( Column::new().spacing(10).width(Length::Fill), |col, (i, address)| { addresses_count += 1; - col.push(address_card(i, address, labels, labels_editing)) + // i is already the correct index since we're iterating forwards then reversing + let is_payjoin = derivation_indexes + .get(i) + .map(|idx| active_payjoin_sessions.contains(idx)) + .unwrap_or(false); + let maybe_bip21 = derivation_indexes + .get(i) + .and_then(|idx| bip21_map.get(idx).cloned()); + col.push(address_card( + i, + address, + maybe_bip21, + labels, + labels_editing, + is_payjoin, + )) }, )), ) @@ -162,35 +219,46 @@ pub fn receive<'a>( // prev addresses are already ordered in descending order Column::new().spacing(10).width(Length::Fill), |col, (i, address)| { + let is_payjoin = prev_derivation_indexes + .get(i) + .map(|idx| active_payjoin_sessions.contains(idx)) + .unwrap_or(false); col.push(if !selected.contains(address) { Button::new( Row::new() .spacing(10) - .push( - { - let addr = address.to_string(); - let addr_len = addr.chars().count(); - Container::new( - p2_regular(if addr_len > 2 * NUM_ADDR_CHARS { - format!( - "{}...{}", - addr.chars() - .take(NUM_ADDR_CHARS) - .collect::(), - addr.chars() - .skip(addr_len - NUM_ADDR_CHARS) - .collect::(), - ) - } else { - addr - }) - .small() - .style(theme::text::secondary), - ) - } - .padding(10) - .width(Length::Fixed(350.0)), - ) + .push({ + let addr = address.to_string(); + let addr_len = addr.chars().count(); + Container::new( + Row::new() + .spacing(5) + .push( + p2_regular(if addr_len > 2 * NUM_ADDR_CHARS { + format!( + "{}...{}", + addr.chars() + .take(NUM_ADDR_CHARS) + .collect::(), + addr.chars() + .skip(addr_len - NUM_ADDR_CHARS) + .collect::(), + ) + } else { + addr + }) + .small() + .style(theme::text::secondary), + ) + .padding(10) + .width(Length::Fixed(350.0)), + ) + }) + .push(if is_payjoin { + badge::payjoin() + } else { + Container::new(p2_regular("").small()) + }) .push( Container::new( scrollable( @@ -226,11 +294,17 @@ pub fn receive<'a>( .style(theme::button::secondary) } else { // Continue the row index from those of generated addresses above. + let is_payjoin = prev_derivation_indexes + .get(i) + .map(|idx| active_payjoin_sessions.contains(idx)) + .unwrap_or(false); Button::new(address_card( addresses_count + i, address, + None, prev_labels, labels_editing, + is_payjoin, )) .padding(0) // so that button & card borders match .on_press(Message::SelectAddress(address.clone())) @@ -348,6 +422,7 @@ pub fn verify_address_modal<'a>( } pub fn qr_modal<'a>(qr: &'a qr_code::Data, address: &'a String) -> Element<'a, Message> { + let max_width = if address.len() > 64 { 600 } else { 400 }; Column::new() .push( Row::new() @@ -361,6 +436,6 @@ pub fn qr_modal<'a>(qr: &'a qr_code::Data, address: &'a String) -> Element<'a, M .push(Space::with_height(Length::Fixed(15.0))) .push(Container::new(text(address).size(15)).center_x(Length::Fill)) .width(Length::Fill) - .max_width(400) + .max_width(max_width) .into() } diff --git a/liana-gui/src/daemon/client/mod.rs b/liana-gui/src/daemon/client/mod.rs index 6341477d9..8b2e4da00 100644 --- a/liana-gui/src/daemon/client/mod.rs +++ b/liana-gui/src/daemon/client/mod.rs @@ -5,6 +5,7 @@ use std::iter::FromIterator; use async_trait::async_trait; use lianad::bip329::Labels; use lianad::commands::{GetLabelsBip329Result, UpdateDerivIndexesResult}; +use lianad::payjoin::types::PayjoinStatus; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -107,6 +108,25 @@ impl Daemon for Lianad { ) } + async fn receive_payjoin(&self) -> Result { + self.call("receivepayjoin", Option::::None) + } + + async fn get_payjoin_info(&self, txid: &Txid) -> Result { + self.call("getpayjoininfo", Some(vec![txid.to_string()])) + } + + async fn get_active_payjoin_sessions(&self) -> Result, DaemonError> { + self.call("getactivepayjoinsessions", Option::::None) + } + + async fn get_payjoin_bip21( + &self, + derivation_index: u32, + ) -> Result, DaemonError> { + self.call("getpayjoinbip21", Some(vec![derivation_index.to_string()])) + } + async fn update_deriv_indexes( &self, receive: Option, diff --git a/liana-gui/src/daemon/embedded.rs b/liana-gui/src/daemon/embedded.rs index 59213b2ea..2af2e2d02 100644 --- a/liana-gui/src/daemon/embedded.rs +++ b/liana-gui/src/daemon/embedded.rs @@ -1,5 +1,5 @@ -use lianad::bip329::Labels; use lianad::commands::UpdateDerivIndexesResult; +use lianad::{bip329::Labels, payjoin::types::PayjoinStatus}; use std::collections::{HashMap, HashSet}; use tokio::sync::Mutex; @@ -120,6 +120,45 @@ impl Daemon for EmbeddedDaemon { .await } + async fn receive_payjoin(&self) -> Result { + self.command(|daemon| { + daemon + .receive_payjoin() + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + + async fn get_payjoin_info(&self, txid: &Txid) -> Result { + self.command(|daemon| { + daemon + .get_payjoin_info(txid) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + + async fn get_active_payjoin_sessions(&self) -> Result, DaemonError> { + self.command(|daemon| { + daemon + .get_active_payjoin_sessions() + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + + async fn get_payjoin_bip21( + &self, + derivation_index: u32, + ) -> Result, DaemonError> { + self.command(|daemon| { + daemon + .get_payjoin_bip21(derivation_index) + .map_err(|e| DaemonError::Unexpected(e.to_string())) + }) + .await + } + async fn update_deriv_indexes( &self, receive: Option, diff --git a/liana-gui/src/daemon/mod.rs b/liana-gui/src/daemon/mod.rs index 838a894b3..e05d9a3bc 100644 --- a/liana-gui/src/daemon/mod.rs +++ b/liana-gui/src/daemon/mod.rs @@ -18,6 +18,7 @@ use liana::miniscript::bitcoin::{ }; use lianad::bip329::Labels; use lianad::commands::UpdateDerivIndexesResult; +use lianad::payjoin::types::PayjoinStatus; use lianad::{ commands::{CoinStatus, LabelItem, TransactionInfo}, config::Config, @@ -118,6 +119,11 @@ pub trait Daemon: Debug { limit: usize, start_index: Option, ) -> Result; + async fn receive_payjoin(&self) -> Result; + async fn get_payjoin_info(&self, txid: &Txid) -> Result; + async fn get_active_payjoin_sessions(&self) -> Result, DaemonError>; + async fn get_payjoin_bip21(&self, derivation_index: u32) + -> Result, DaemonError>; async fn update_deriv_indexes( &self, receive: Option, @@ -212,6 +218,10 @@ pub trait Daemon: Debug { .cloned() .collect(); + let payjoin_status = self + .get_payjoin_info(&tx.psbt.unsigned_tx.compute_txid()) + .await?; + spend_txs.push(model::SpendTx::new( tx.updated_at, tx.psbt, @@ -219,6 +229,7 @@ pub trait Daemon: Debug { &info.descriptors.main, &curve, info.network, + Some(payjoin_status), )); } load_labels(self, &mut spend_txs).await?; diff --git a/liana-gui/src/daemon/model.rs b/liana-gui/src/daemon/model.rs index f9dc00f14..5b3bfcddc 100644 --- a/liana-gui/src/daemon/model.rs +++ b/liana-gui/src/daemon/model.rs @@ -15,6 +15,7 @@ pub use lianad::commands::{ ListCoinsResult, ListRevealedAddressesEntry, ListRevealedAddressesResult, ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, }; +use lianad::payjoin::types::PayjoinStatus; pub type Coin = ListCoinsEntry; @@ -53,6 +54,7 @@ pub struct SpendTx { pub sigs: PartialSpendInfo, pub updated_at: Option, pub kind: TransactionKind, + pub payjoin_status: Option, } #[derive(PartialOrd, Ord, Debug, Clone, PartialEq, Eq)] @@ -61,6 +63,8 @@ pub enum SpendStatus { Broadcast, Spent, Deprecated, + PayjoinInitiated, + PayjoinProposalReady, } impl SpendTx { @@ -71,6 +75,7 @@ impl SpendTx { desc: &LianaDescriptor, secp: &secp256k1::Secp256k1, network: Network, + payjoin_status: Option, ) -> Self { // Use primary path if no inputs are using a relative locktime. let use_primary_path = !psbt @@ -145,7 +150,7 @@ impl SpendTx { }; // One input coin is missing, the psbt is deprecated for now. - if coins_map.len() != psbt.inputs.len() { + if coins_map.len() != psbt.inputs.len() && payjoin_status.is_none() { status = SpendStatus::Deprecated } @@ -190,6 +195,7 @@ impl SpendTx { status, sigs, network, + payjoin_status, } } diff --git a/liana-gui/src/installer/step/node/bitcoind.rs b/liana-gui/src/installer/step/node/bitcoind.rs index e692053ac..1f2f6aee4 100644 --- a/liana-gui/src/installer/step/node/bitcoind.rs +++ b/liana-gui/src/installer/step/node/bitcoind.rs @@ -248,7 +248,6 @@ fn bitcoind_default_address(network: &Network) -> String { Network::Testnet4 => "127.0.0.1:48332".to_string(), Network::Regtest => "127.0.0.1:18443".to_string(), Network::Signet => "127.0.0.1:38332".to_string(), - _ => "127.0.0.1:8332".to_string(), } } diff --git a/liana-gui/src/installer/step/wallet_alias.rs b/liana-gui/src/installer/step/wallet_alias.rs index 60ceb4598..e41151e52 100644 --- a/liana-gui/src/installer/step/wallet_alias.rs +++ b/liana-gui/src/installer/step/wallet_alias.rs @@ -35,7 +35,6 @@ impl Step for WalletAlias { Network::Testnet => "Testnet", Network::Testnet4 => "Testnet4", Network::Regtest => "Regtest", - _ => "", } ); self.wallet_alias.valid = true; diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index e030e9a6f..ec0ddf388 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -384,7 +384,6 @@ fn wallets_list_item( Network::Testnet => "Testnet", Network::Testnet4 => "Testnet4", Network::Regtest => "Regtest", - _ => "", } )) }) diff --git a/liana-gui/src/node/bitcoind.rs b/liana-gui/src/node/bitcoind.rs index d2274e35e..c0e90d072 100644 --- a/liana-gui/src/node/bitcoind.rs +++ b/liana-gui/src/node/bitcoind.rs @@ -138,7 +138,6 @@ pub fn bitcoind_network_dir(network: &Network) -> Option { Network::Testnet4 => "testnet4", Network::Regtest => "regtest", Network::Signet => "signet", - _ => panic!("Directory required for this network is unknown."), }; Some(dir.to_string()) } diff --git a/liana-gui/src/services/connect/client/backend/mod.rs b/liana-gui/src/services/connect/client/backend/mod.rs index f2a6e52d8..4c1c6676b 100644 --- a/liana-gui/src/services/connect/client/backend/mod.rs +++ b/liana-gui/src/services/connect/client/backend/mod.rs @@ -20,6 +20,7 @@ use lianad::{ bip329::Labels, commands::{CoinStatus, GetInfoDescriptors, LCSpendInfo, LabelItem, UpdateDerivIndexesResult}, config::Config, + payjoin::types::PayjoinStatus, }; use reqwest::{Error, IntoUrl, Method, RequestBuilder}; use tokio::sync::RwLock; @@ -612,6 +613,7 @@ impl Daemon for BackendWalletClient { Ok(GetAddressResult { address: res.address, derivation_index: res.derivation_index, + bip21: None, }) } @@ -653,6 +655,25 @@ impl Daemon for BackendWalletClient { }) } + async fn receive_payjoin(&self) -> Result { + unimplemented!() + } + + async fn get_payjoin_info(&self, _txid: &Txid) -> Result { + unimplemented!() + } + + async fn get_active_payjoin_sessions(&self) -> Result, DaemonError> { + unimplemented!() + } + + async fn get_payjoin_bip21( + &self, + _derivation_index: u32, + ) -> Result, DaemonError> { + unimplemented!() + } + async fn update_deriv_indexes( &self, _receive: Option, @@ -1210,6 +1231,7 @@ fn spend_tx_from_api( desc, secp, network, + None, ); tx.load_labels(&labels); tx From 0ab1f5a589e39325bb69331ff6e9c6ee6ef8bb6a Mon Sep 17 00:00:00 2001 From: Benalleng Date: Fri, 20 Feb 2026 13:54:04 -0500 Subject: [PATCH 06/10] Update lock for payjoin addition --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index f477c19dc..d14066eea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3517,6 +3517,7 @@ dependencies = [ "libc", "log", "open", + "payjoin", "reqwest 0.11.27", "rfd", "rust-ini", From 44b658c35165087847555e213a109cda7e247fd6 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Thu, 12 Mar 2026 09:27:59 -0400 Subject: [PATCH 07/10] Add adjustable settings for payjoin server connections --- liana-gui/src/app/state/settings/mod.rs | 10 ++ liana-gui/src/app/state/settings/payjoin.rs | 173 ++++++++++++++++++++ liana-gui/src/app/view/message.rs | 2 + liana-gui/src/app/view/settings/mod.rs | 162 ++++++++++++++++++ lianad/src/commands/mod.rs | 28 +++- lianad/src/config.rs | 26 +++ lianad/src/payjoin/helpers.rs | 1 - 7 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 liana-gui/src/app/state/settings/payjoin.rs diff --git a/liana-gui/src/app/state/settings/mod.rs b/liana-gui/src/app/state/settings/mod.rs index 76e309750..7abe6970e 100644 --- a/liana-gui/src/app/state/settings/mod.rs +++ b/liana-gui/src/app/state/settings/mod.rs @@ -1,5 +1,6 @@ mod bitcoind; mod general; +pub mod payjoin; pub mod wallet; use std::convert::From; @@ -10,6 +11,7 @@ use iced::{Subscription, Task}; use liana_ui::{component::form, widget::Element}; use bitcoind::BitcoindSettingsState; +use payjoin::PayjoinSettingsState; use wallet::{update_aliases, WalletSettingsState}; use crate::{ @@ -161,6 +163,14 @@ impl SettingsUI for LianaSettingsUI { .map(|s| s.reload(daemon, wallet)) .unwrap_or_else(Task::none) } + Message::View(view::Message::Settings(view::SettingsMessage::EditPayjoinSettings)) => { + self.setting = Some(PayjoinSettingsState::new(daemon.config().cloned()).into()); + let wallet = self.wallet.clone(); + self.setting + .as_mut() + .map(|s| s.reload(daemon, wallet)) + .unwrap_or_else(Task::none) + } Message::WalletUpdated(Ok(wallet)) => { self.wallet = wallet.clone(); self.setting diff --git a/liana-gui/src/app/state/settings/payjoin.rs b/liana-gui/src/app/state/settings/payjoin.rs new file mode 100644 index 000000000..6c53d3ce8 --- /dev/null +++ b/liana-gui/src/app/state/settings/payjoin.rs @@ -0,0 +1,173 @@ +use std::sync::Arc; + +use iced::{clipboard, Task}; +use liana_ui::{component::form, widget::Element}; + +use lianad::config::{Config as DaemonConfig, PayjoinConfig}; + +use crate::{ + app::{cache::Cache, error::Error, message::Message, state::settings::State, view}, + daemon::Daemon, +}; + +#[derive(Debug)] +pub struct PayjoinSettingsState { + warning: Option, + config_updated: bool, + payjoin_settings: PayjoinSettings, +} + +impl PayjoinSettingsState { + pub fn new(config: Option) -> Self { + let payjoin_config = + config + .and_then(|c| c.payjoin_config) + .unwrap_or_else(|| PayjoinConfig { + ohttp_relay: "https://pj.bobspacebkk.com".to_string(), + payjoin_directory: "https://payjo.in".to_string(), + }); + PayjoinSettingsState { + warning: None, + config_updated: false, + payjoin_settings: PayjoinSettings::new(payjoin_config), + } + } +} + +impl State for PayjoinSettingsState { + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Task { + match message { + Message::DaemonConfigLoaded(res) => match res { + Ok(()) => { + self.config_updated = true; + self.warning = None; + self.payjoin_settings.edited(true); + return Task::perform(async {}, |_| { + Message::View(view::Message::Settings( + view::SettingsMessage::EditPayjoinSettings, + )) + }); + } + Err(e) => { + self.config_updated = false; + self.warning = Some(e); + self.payjoin_settings.edited(false); + } + }, + Message::View(view::Message::Settings(view::SettingsMessage::PayjoinSettings(msg))) => { + return self.payjoin_settings.update(daemon, msg); + } + _ => {} + }; + Task::none() + } + + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + view::settings::payjoin_settings( + cache, + self.warning.as_ref(), + Some(self.payjoin_settings.view()), + ) + } +} + +impl From for Box { + fn from(s: PayjoinSettingsState) -> Box { + Box::new(s) + } +} + +#[derive(Debug)] +pub struct PayjoinSettings { + edit: bool, + processing: bool, + ohttp_relay: form::Value, + payjoin_directory: form::Value, +} + +impl PayjoinSettings { + pub fn new(config: PayjoinConfig) -> Self { + PayjoinSettings { + edit: false, + processing: false, + ohttp_relay: form::Value { + valid: true, + warning: None, + value: config.ohttp_relay, + }, + payjoin_directory: form::Value { + valid: true, + warning: None, + value: config.payjoin_directory, + }, + } + } +} + +impl PayjoinSettings { + fn edited(&mut self, success: bool) { + self.processing = false; + if success { + self.edit = false; + } + } + + fn update( + &mut self, + daemon: Arc, + message: view::SettingsEditMessage, + ) -> Task { + match message { + view::SettingsEditMessage::Select => { + if !self.processing { + self.edit = true; + } + } + view::SettingsEditMessage::Cancel => { + if !self.processing { + self.edit = false; + } + } + view::SettingsEditMessage::FieldEdited(field, value) => { + if !self.processing { + match field { + "ohttp_relay" => self.ohttp_relay.value = value, + "payjoin_directory" => self.payjoin_directory.value = value, + _ => {} + } + } + } + view::SettingsEditMessage::Confirm => { + let mut daemon_config = daemon.config().cloned().unwrap(); + daemon_config.payjoin_config = Some(PayjoinConfig::new( + self.ohttp_relay.value.clone(), + self.payjoin_directory.value.clone(), + )); + self.processing = true; + return Task::perform(async move { daemon_config }, |cfg| { + Message::LoadDaemonConfig(Box::new(cfg)) + }); + } + view::SettingsEditMessage::Clipboard(text) => return clipboard::write(text), + _ => {} + }; + Task::none() + } + + fn view<'a>(&self) -> Element<'a, view::SettingsEditMessage> { + if self.edit { + view::settings::payjoin_edit( + &self.ohttp_relay, + &self.payjoin_directory, + self.processing, + ) + } else { + view::settings::payjoin(&self.ohttp_relay.value, &self.payjoin_directory.value) + } + } +} diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index daffd6d6e..e83bd86bb 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -118,6 +118,8 @@ pub enum SettingsMessage { Save, GeneralSection, Fiat(FiatMessage), + EditPayjoinSettings, + PayjoinSettings(SettingsEditMessage), } #[derive(Debug, Clone)] diff --git a/liana-gui/src/app/view/settings/mod.rs b/liana-gui/src/app/view/settings/mod.rs index 9a65f2541..c77a8589b 100644 --- a/liana-gui/src/app/view/settings/mod.rs +++ b/liana-gui/src/app/view/settings/mod.rs @@ -146,6 +146,13 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element { Message::Settings(SettingsMessage::EditWalletSettings), ); + let payjoin = settings_section( + "Payjoin", + None, + icon::bitcoin_icon(), + Message::Settings(SettingsMessage::EditPayjoinSettings), + ); + let import_export = settings_section( "Import/Export", None, @@ -171,6 +178,7 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element { .push(general) .push(if !is_remote_backend { node } else { backend }) .push(wallet) + .push(payjoin) .push(import_export) .push(about), ) @@ -206,6 +214,160 @@ pub fn bitcoind_settings<'a>( ) } +pub fn payjoin_settings<'a>( + cache: &'a Cache, + warning: Option<&Error>, + settings: Option>, +) -> Element<'a, Message> { + let header = header("Payjoin", SettingsMessage::EditPayjoinSettings); + + dashboard( + &Menu::Settings, + cache, + warning, + Column::new().spacing(20).push(header).push_maybe( + settings.map(|s| s.map(|msg| Message::Settings(SettingsMessage::PayjoinSettings(msg)))), + ), + ) +} + +pub fn payjoin_edit<'a>( + ohttp_relay: &form::Value, + payjoin_directory: &form::Value, + processing: bool, +) -> Element<'a, SettingsEditMessage> { + let mut col = Column::new().spacing(20); + + col = col.push( + Column::new() + .push(text("OHTTP Relay:").bold().small()) + .push( + form::Form::new_trimmed("https://pj.example.com", ohttp_relay, |value| { + SettingsEditMessage::FieldEdited("ohttp_relay", value) + }) + .warning("Please enter a valid URL") + .size(P1_SIZE) + .padding(5), + ) + .spacing(5), + ); + + col = col.push( + Column::new() + .push(text("Payjoin Directory:").bold().small()) + .push( + form::Form::new_trimmed("https://payjo.in", payjoin_directory, |value| { + SettingsEditMessage::FieldEdited("payjoin_directory", value) + }) + .warning("Please enter a valid URL") + .size(P1_SIZE) + .padding(5), + ) + .spacing(5), + ); + + let mut cancel_button = button::transparent(None, " Cancel ").padding(5); + let mut confirm_button = button::secondary(None, " Save ").padding(5); + if !processing { + cancel_button = cancel_button.on_press(SettingsEditMessage::Cancel); + confirm_button = confirm_button.on_press(SettingsEditMessage::Confirm); + } + + card::simple(Container::new( + Column::new() + .push( + Row::new() + .push(badge::badge(icon::bitcoin_icon())) + .push(text("Payjoin").bold()) + .padding(10) + .spacing(20) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .push(separation().width(Length::Fill)) + .push(col) + .push( + Container::new( + Row::new() + .push(cancel_button) + .push(confirm_button) + .spacing(10) + .align_y(Alignment::Center), + ) + .width(Length::Fill) + .align_x(alignment::Horizontal::Right), + ) + .spacing(20), + )) + .width(Length::Fill) + .into() +} + +pub fn payjoin<'a>(ohttp_relay: &str, payjoin_directory: &str) -> Element<'a, SettingsEditMessage> { + let rows = vec![ + ("OHTTP Relay:", ohttp_relay.to_string()), + ("Payjoin Directory:", payjoin_directory.to_string()), + ]; + + let mut col_fields = Column::new(); + for (k, v) in rows { + let v_clone = v.clone(); + col_fields = col_fields.push( + Row::new() + .push(Container::new(text(k).bold().small()).width(Length::FillPortion(1))) + .push( + Container::new( + scrollable( + Column::new() + .push(Space::with_height(Length::Fixed(10.0))) + .push(text(v).small()) + .push(Space::with_height(Length::Fixed(10.0))), + ) + .direction(scrollable::Direction::Horizontal( + scrollable::Scrollbar::new().width(2).scroller_width(2), + )), + ) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::FillPortion(3)), + ) + .push(Space::with_width(10)) + .push( + Button::new(icon::clipboard_icon()) + .style(theme::button::transparent_border) + .on_press(SettingsEditMessage::Clipboard(v_clone)), + ) + .align_y(Alignment::Center), + ); + } + + card::simple(Container::new( + Column::new() + .push( + Row::new() + .push( + Row::new() + .push(badge::badge(icon::bitcoin_icon())) + .push(text("Payjoin").bold()) + .spacing(20) + .align_y(Alignment::Center) + .width(Length::Fill), + ) + .push( + Button::new(icon::pencil_icon()) + .style(theme::button::transparent_border) + .on_press(SettingsEditMessage::Select), + ) + .align_y(Alignment::Center), + ) + .push(separation().width(Length::Fill)) + .push(col_fields) + .spacing(20), + )) + .width(Length::Fill) + .into() +} + pub fn import_export<'a>(cache: &'a Cache, warning: Option<&Error>) -> Element<'a, Message> { let header = header("Import/Export", SettingsMessage::ImportExportSection); diff --git a/lianad/src/commands/mod.rs b/lianad/src/commands/mod.rs index 21067930a..bf39efedd 100644 --- a/lianad/src/commands/mod.rs +++ b/lianad/src/commands/mod.rs @@ -6,11 +6,12 @@ mod utils; use crate::{ bitcoin::BitcoinInterface, + config::Config, database::{Coin, DatabaseConnection, DatabaseInterface}, miniscript::bitcoin::absolute::LockTime, payjoin::{ db::ReceiverPersister, - helpers::{fetch_ohttp_keys, FetchOhttpKeysError, OHTTP_RELAY, PAYJOIN_DIRECTORY}, + helpers::{fetch_ohttp_keys, FetchOhttpKeysError}, types::PayjoinStatus, }, poller::PollerMessage, @@ -387,15 +388,24 @@ impl DaemonControl { pub fn receive_payjoin(&self) -> Result { let mut db_conn = self.db.connection(); - let ohttp_keys = if let Some(entry) = db_conn.payjoin_get_ohttp_keys(OHTTP_RELAY) { + let payjoin_config = self + .config + .payjoin_config + .clone() + .unwrap_or_else(Config::default_payjoin_config); + let ohttp_relay_url = payjoin_config.ohttp_relay.clone(); + let payjoin_dir_url = payjoin_config.payjoin_directory.clone(); + + let ohttp_keys = if let Some(entry) = db_conn.payjoin_get_ohttp_keys(&ohttp_relay_url) { entry.1 } else { - let ohttp_keys = - std::thread::spawn(move || fetch_ohttp_keys(OHTTP_RELAY, PAYJOIN_DIRECTORY)) - .join() - .unwrap() - .map_err(CommandError::FailedToFetchOhttpKeys)?; - db_conn.payjoin_save_ohttp_keys(OHTTP_RELAY, ohttp_keys.clone()); + let ohttp_relay = ohttp_relay_url.clone(); + let payjoin_dir = payjoin_dir_url.clone(); + let ohttp_keys = std::thread::spawn(move || fetch_ohttp_keys(ohttp_relay, payjoin_dir)) + .join() + .unwrap() + .map_err(CommandError::FailedToFetchOhttpKeys)?; + db_conn.payjoin_save_ohttp_keys(&ohttp_relay_url, ohttp_keys.clone()); ohttp_keys }; @@ -412,7 +422,7 @@ impl DaemonControl { .address(self.config.bitcoin_config.network); let persister = ReceiverPersister::new(Arc::new(self.db.clone()), new_index.into(), ""); - let session = ReceiverBuilder::new(address.clone(), PAYJOIN_DIRECTORY, ohttp_keys) + let session = ReceiverBuilder::new(address.clone(), payjoin_dir_url, ohttp_keys) .map_err(|e| CommandError::IntoUrlError(e.to_string()))? .build() .save(&persister) diff --git a/lianad/src/config.rs b/lianad/src/config.rs index 2d0b2f2c5..5e2843712 100644 --- a/lianad/src/config.rs +++ b/lianad/src/config.rs @@ -139,6 +139,21 @@ fn default_validate_domain() -> bool { true } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct PayjoinConfig { + pub ohttp_relay: String, + pub payjoin_directory: String, +} + +impl PayjoinConfig { + pub fn new(ohttp_relay: String, payjoin_directory: String) -> Self { + Self { + ohttp_relay, + payjoin_directory, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct BitcoinConfig { /// The network we are operating on, one of "bitcoin", "testnet", "testnet4", "regtest", "signet" @@ -177,6 +192,9 @@ pub struct Config { /// Settings specific to the Bitcoin backend. #[serde(flatten)] pub bitcoin_backend: Option, + /// Settings for Payjoin. + #[serde(default)] + pub payjoin_config: Option, } impl Config { @@ -194,6 +212,14 @@ impl Config { main_descriptor, data_directory: Some(data_directory.path().to_path_buf()), data_dir: None, + payjoin_config: None, + } + } + + pub fn default_payjoin_config() -> PayjoinConfig { + PayjoinConfig { + ohttp_relay: "https://pj.bobspacebkk.com".to_string(), + payjoin_directory: "https://payjo.in".to_string(), } } diff --git a/lianad/src/payjoin/helpers.rs b/lianad/src/payjoin/helpers.rs index dd31c76f2..61826b3ca 100644 --- a/lianad/src/payjoin/helpers.rs +++ b/lianad/src/payjoin/helpers.rs @@ -9,7 +9,6 @@ use payjoin::{bitcoin::Amount, IntoUrl, OhttpKeys}; use reqwest::{header::ACCEPT, Proxy}; pub(crate) const OHTTP_RELAY: &str = "https://pj.bobspacebkk.com"; -pub(crate) const PAYJOIN_DIRECTORY: &str = "https://payjo.in"; pub(crate) fn http_agent() -> reqwest::blocking::Client { reqwest::blocking::Client::new() From aaed3b66ad6bfc0444ed1d308ddffff2269d69b0 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Tue, 17 Mar 2026 12:30:31 -0400 Subject: [PATCH 08/10] fixup! Add adjustable settings for payjoin server connections --- liana-gui/src/app/state/psbt.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/liana-gui/src/app/state/psbt.rs b/liana-gui/src/app/state/psbt.rs index 7efca2d11..809e2b6ad 100644 --- a/liana-gui/src/app/state/psbt.rs +++ b/liana-gui/src/app/state/psbt.rs @@ -748,6 +748,12 @@ mod tests { }]})), ), + ( + Some( + json!({"method": "getpayjoininfo", "params": vec!["4bc07e8fe753f7314b69da02a7cfbedc3e4e0d5fbee316a048240ae87b8aaa58"]}), + ), + Ok(json!({ "Unknown": null})), + ), ( Some(json!({"method": "getlabels", "params": vec![vec![ "4bc07e8fe753f7314b69da02a7cfbedc3e4e0d5fbee316a048240ae87b8aaa58", From 93db05b8a0b6264eab309b284498c30753074e57 Mon Sep 17 00:00:00 2001 From: Benalleng Date: Tue, 17 Mar 2026 12:54:48 -0400 Subject: [PATCH 09/10] Add payjoin specific test utils --- lianad/src/testutils.rs | 130 +++++++++++++++++++++++++++++----------- 1 file changed, 96 insertions(+), 34 deletions(-) diff --git a/lianad/src/testutils.rs b/lianad/src/testutils.rs index 64e895cd0..c1a48d3d5 100644 --- a/lianad/src/testutils.rs +++ b/lianad/src/testutils.rs @@ -5,9 +5,11 @@ use crate::{ BlockInfo, Coin, CoinStatus, DatabaseConnection, DatabaseInterface, LabelItem, Wallet, }, datadir::DataDirectory, + payjoin::db::SessionId, DaemonControl, DaemonHandle, }; use liana::descriptors; +use payjoin::OhttpKeys; use std::convert::TryInto; use std::{ @@ -160,6 +162,11 @@ struct DummyDbState { timestamp: u32, rescan_timestamp: Option, last_poll_timestamp: Option, + ohttp_keys: HashMap, + payjoin_receiver_sessions: HashMap)>, + payjoin_sessions_by_derivation: HashMap, + payjoin_sessions_by_txid: HashMap, + receiver_session_events: HashMap>>, } pub struct DummyDatabase { @@ -195,6 +202,11 @@ impl DummyDatabase { timestamp: now, rescan_timestamp: None, last_poll_timestamp: None, + ohttp_keys: HashMap::new(), + payjoin_receiver_sessions: HashMap::new(), + payjoin_sessions_by_derivation: HashMap::new(), + payjoin_sessions_by_txid: HashMap::new(), + receiver_session_events: HashMap::new(), })), } } @@ -555,65 +567,115 @@ impl DatabaseConnection for DummyDatabase { todo!() } - fn payjoin_get_ohttp_keys(&mut self, _ohttp_relay: &str) -> Option<(u32, payjoin::OhttpKeys)> { - todo!() + fn payjoin_get_ohttp_keys(&mut self, ohttp_relay: &str) -> Option<(u32, OhttpKeys)> { + self.db.read().unwrap().ohttp_keys.get(ohttp_relay).cloned() } - fn payjoin_save_ohttp_keys(&mut self, _ohttp_relay: &str, _ohttp_keys: payjoin::OhttpKeys) { - todo!() + fn payjoin_save_ohttp_keys(&mut self, ohttp_relay: &str, ohttp_keys: OhttpKeys) { + self.db + .write() + .unwrap() + .ohttp_keys + .insert(ohttp_relay.to_string(), (0, ohttp_keys)); } fn insert_input_seen_before(&mut self, _outpoints: &[bitcoin::OutPoint]) -> bool { - todo!() + false } - fn get_active_payjoin_sessions(&mut self) -> Vec<(crate::payjoin::db::SessionId, u32)> { - todo!() + fn get_active_payjoin_sessions(&mut self) -> Vec<(SessionId, u32)> { + Vec::new() } - fn save_receiver_session_event( - &mut self, - _session_id: &crate::payjoin::db::SessionId, - _event: Vec, - ) { - todo!() + fn save_receiver_session_event(&mut self, session_id: &SessionId, event: Vec) { + let mut db = self.db.write().unwrap(); + let session_events = db.receiver_session_events.entry(session_id.0).or_default(); + session_events.push(event); } - fn load_receiver_session_events( - &mut self, - _session_id: &crate::payjoin::db::SessionId, - ) -> Vec> { - todo!() + fn load_receiver_session_events(&mut self, session_id: &SessionId) -> Vec> { + self.db + .read() + .unwrap() + .receiver_session_events + .get(&session_id.0) + .cloned() + .unwrap_or_default() } - fn save_new_payjoin_receiver_session(&mut self, _derivation_index: u32, _bip21: &str) -> i64 { - todo!() + fn save_new_payjoin_receiver_session(&mut self, derivation_index: u32, bip21: &str) -> i64 { + let mut db = self.db.write().unwrap(); + let session_id = (db.payjoin_receiver_sessions.len() + 1) as i64; + db.payjoin_receiver_sessions + .insert(session_id, (derivation_index, bip21.to_string(), None)); + db.payjoin_sessions_by_derivation + .insert(derivation_index, session_id); + session_id } - fn get_payjoin_receiver_bip21(&mut self, _derivation_index: u32) -> Option { - todo!() + fn get_payjoin_receiver_bip21(&mut self, derivation_index: u32) -> Option { + self.db + .read() + .unwrap() + .payjoin_sessions_by_derivation + .get(&derivation_index) + .and_then(|session_id| { + self.db + .read() + .unwrap() + .payjoin_receiver_sessions + .get(session_id) + .map(|(_, bip21, _)| bip21.clone()) + }) } - fn update_payjoin_receiver_bip21(&mut self, _derivation_index: u32, _bip21: &str) { - todo!() + fn update_payjoin_receiver_bip21(&mut self, derivation_index: u32, bip21: &str) { + let mut db = self.db.write().unwrap(); + if let Some(session_id) = db + .payjoin_sessions_by_derivation + .get(&derivation_index) + .copied() + { + if let Some(session) = db.payjoin_receiver_sessions.get_mut(&session_id) { + session.1 = bip21.to_string(); + } + } } - fn get_all_active_receiver_session_ids(&mut self) -> Vec { - todo!() + fn get_all_active_receiver_session_ids(&mut self) -> Vec { + self.db + .read() + .unwrap() + .payjoin_receiver_sessions + .iter() + .filter(|(_, (_, _, completed_at))| completed_at.is_none()) + .map(|(id, _)| SessionId::new(*id)) + .collect() } - fn update_receiver_session_completed_at( - &mut self, - _session_id: &crate::payjoin::db::SessionId, - ) { - todo!() + fn update_receiver_session_completed_at(&mut self, session_id: &SessionId) { + let mut db = self.db.write().unwrap(); + if let Some(session) = db.payjoin_receiver_sessions.get_mut(&session_id.0) { + let now: u32 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .try_into() + .unwrap(); + session.2 = Some(now); + } } fn get_payjoin_receiver_session_id_from_txid( &mut self, - _txid: &bitcoin::Txid, - ) -> Option { - todo!() + txid: &bitcoin::Txid, + ) -> Option { + self.db + .read() + .unwrap() + .payjoin_sessions_by_txid + .get(txid) + .map(|id| SessionId::new(*id)) } } From da7ff64e4c96fcf570e0286381af4da4b5ce234d Mon Sep 17 00:00:00 2001 From: Benalleng Date: Wed, 18 Mar 2026 16:03:45 -0400 Subject: [PATCH 10/10] try fix for functional tests --- lianad/src/commands/mod.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lianad/src/commands/mod.rs b/lianad/src/commands/mod.rs index bf39efedd..024d01bab 100644 --- a/lianad/src/commands/mod.rs +++ b/lianad/src/commands/mod.rs @@ -1006,7 +1006,20 @@ impl DaemonControl { for index in 0..spend_psbt.inputs.len() { match spend_psbt.finalize_inp_mut(&self.secp, index) { Ok(_) => log::debug!("Finalizing input at: {}", index), - Err(e) => log::error!("Not finalizing input at: {} | {}", index, e), + Err(e) => { + // If the input is already finalized (e.g. a payjoin sender input that + // arrived with final_script_witness already set), ignore the error. + // Otherwise, the transaction can't be broadcast — return an error. + let input = &spend_psbt.inputs[index]; + if input.final_script_witness.is_none() && input.final_script_sig.is_none() { + return Err(CommandError::SpendFinalization(e.to_string())); + } + log::debug!( + "Input at index {} already finalized, skipping: {}", + index, + e + ); + } } }