Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ prost = { version = "0.11.6", default-features = false}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "6796e87525d6c564e1332354a808730e2ba2ebf8" }

payjoin = { git = "https://github.com/payjoin/rust-payjoin.git", package = "payjoin", default-features = false, features = ["v2", "io"] }

[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winbase"] }

Expand Down
91 changes: 91 additions & 0 deletions src/chain/bitcoind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,57 @@ impl BitcoindChainSource {
}
}
}

pub(crate) async fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
let timeout_fut = tokio::time::timeout(
Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS),
self.api_client.test_mempool_accept(tx),
);

match timeout_fut.await {
Ok(res) => res.map_err(|e| {
log_error!(
self.logger,
"Failed to test mempool accept for transaction {}: {}",
tx.compute_txid(),
e
);
Error::TxBroadcastFailed
}),
Err(e) => {
log_error!(
self.logger,
"Failed to test mempool accept for transaction {} due to timeout: {}",
tx.compute_txid(),
e
);
log_trace!(
self.logger,
"Failed test mempool accept transaction bytes: {}",
log_bytes!(tx.encode())
);
Err(Error::TxBroadcastFailed)
},
}
}

pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
let timeout_fut = tokio::time::timeout(
Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS),
self.api_client.get_raw_transaction(txid),
);

match timeout_fut.await {
Ok(res) => res.map_err(|e| {
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
Error::TxSyncFailed
}),
Err(e) => {
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
Err(Error::TxSyncTimeout)
},
}
}
}

#[derive(Clone)]
Expand Down Expand Up @@ -1235,6 +1286,46 @@ impl BitcoindClient {
.collect();
Ok(evicted_txids)
}

/// Tests whether the provided transaction would be accepted by the mempool.
pub(crate) async fn test_mempool_accept(&self, tx: &Transaction) -> std::io::Result<bool> {
match self {
BitcoindClient::Rpc { rpc_client, .. } => {
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
},
BitcoindClient::Rest { rpc_client, .. } => {
// We rely on the internal RPC client to make this call, as this
// operation is not supported by Bitcoin Core's REST interface.
Self::test_mempool_accept_inner(Arc::clone(rpc_client), tx).await
},
}
}

async fn test_mempool_accept_inner(
rpc_client: Arc<RpcClient>, tx: &Transaction,
) -> std::io::Result<bool> {
let tx_serialized = bitcoin::consensus::encode::serialize_hex(tx);
let tx_array = serde_json::json!([tx_serialized]);

let resp =
rpc_client.call_method::<serde_json::Value>("testmempoolaccept", &[tx_array]).await?;

if let Some(array) = resp.as_array() {
if let Some(first_result) = array.first() {
Ok(first_result.get("allowed").and_then(|v| v.as_bool()).unwrap_or(false))
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"Empty array response from testmempoolaccept",
))
}
} else {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"testmempoolaccept did not return an array",
))
}
}
}

impl BlockSource for BitcoindClient {
Expand Down
128 changes: 127 additions & 1 deletion src/chain/electrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use bdk_chain::bdk_core::spk_client::{
};
use bdk_electrum::BdkElectrumClient;
use bdk_wallet::{KeychainKind as BdkKeyChainKind, Update as BdkUpdate};
use bitcoin::{FeeRate, Network, Script, ScriptBuf, Transaction, Txid};
use bitcoin::{FeeRate, Network, OutPoint, Script, ScriptBuf, Transaction, Txid};
use electrum_client::{
Batch, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, ElectrumApi,
};
Expand Down Expand Up @@ -291,6 +291,21 @@ impl ElectrumChainSource {
electrum_client.broadcast(tx).await;
}
}

pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
let electrum_client: Arc<ElectrumRuntimeClient> =
if let Some(client) = self.electrum_runtime_status.read().unwrap().client().as_ref() {
Arc::clone(client)
} else {
debug_assert!(
false,
"We should have started the chain source before getting transactions"
);
return Err(Error::TxSyncFailed);
};

electrum_client.get_transaction(txid).await
}
}

impl Filter for ElectrumChainSource {
Expand Down Expand Up @@ -632,6 +647,117 @@ impl ElectrumRuntimeClient {

Ok(new_fee_rate_cache)
}

async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
let electrum_client = Arc::clone(&self.electrum_client);
let txid_copy = *txid;

let spawn_fut =
self.runtime.spawn_blocking(move || electrum_client.transaction_get(&txid_copy));
let timeout_fut =
tokio::time::timeout(Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), spawn_fut);

match timeout_fut.await {
Ok(res) => match res {
Ok(inner_res) => match inner_res {
Ok(tx) => Ok(Some(tx)),
Err(e) => {
// Check if it's a "not found" error
let error_str = e.to_string();
if error_str.contains("No such mempool or blockchain transaction")
|| error_str.contains("not found")
{
Ok(None)
} else {
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
Err(Error::TxSyncFailed)
}
},
},
Err(e) => {
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
Err(Error::TxSyncFailed)
},
},
Err(e) => {
log_error!(self.logger, "Failed to get transaction {} due to timeout: {}", txid, e);
Err(Error::TxSyncTimeout)
},
}
}

async fn is_outpoint_spent(&self, outpoint: &OutPoint) -> Result<bool, Error> {
// First get the transaction to find the scriptPubKey of the output
let tx = match self.get_transaction(&outpoint.txid).await? {
Some(tx) => tx,
None => {
// Transaction doesn't exist, so outpoint can't be spent
// (or never existed)
return Ok(false);
},
};

// Check if the output index is valid
let vout = outpoint.vout as usize;
if vout >= tx.output.len() {
// Invalid output index
return Ok(false);
}

let script_pubkey = &tx.output[vout].script_pubkey;
let electrum_client = Arc::clone(&self.electrum_client);
let script_pubkey_clone = script_pubkey.clone();
let outpoint_txid = outpoint.txid;
let outpoint_vout = outpoint.vout;

let spawn_fut = self
.runtime
.spawn_blocking(move || electrum_client.script_list_unspent(&script_pubkey_clone));
let timeout_fut =
tokio::time::timeout(Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), spawn_fut);

match timeout_fut.await {
Ok(res) => match res {
Ok(inner_res) => match inner_res {
Ok(unspent_list) => {
// Check if our outpoint is in the unspent list
let is_unspent = unspent_list.iter().any(|u| {
u.tx_hash == outpoint_txid && u.tx_pos == outpoint_vout as usize
});
// Return true if spent (not in unspent list)
Ok(!is_unspent)
},
Err(e) => {
log_error!(
self.logger,
"Failed to check if outpoint {} is spent: {}",
outpoint,
e
);
Err(Error::TxSyncFailed)
},
},
Err(e) => {
log_error!(
self.logger,
"Failed to check if outpoint {} is spent: {}",
outpoint,
e
);
Err(Error::TxSyncFailed)
},
},
Err(e) => {
log_error!(
self.logger,
"Failed to check if outpoint {} is spent due to timeout: {}",
outpoint,
e
);
Err(Error::TxSyncTimeout)
},
}
}
}

impl Filter for ElectrumRuntimeClient {
Expand Down
7 changes: 7 additions & 0 deletions src/chain/esplora.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,13 @@ impl EsploraChainSource {
}
}
}

pub(crate) async fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
self.esplora_client.get_tx(txid).await.map_err(|e| {
log_error!(self.logger, "Failed to get transaction {}: {}", txid, e);
Error::TxSyncFailed
})
}
}

impl Filter for EsploraChainSource {
Expand Down
34 changes: 33 additions & 1 deletion src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::Duration;

use bitcoin::{Script, Txid};
use bitcoin::{Script, Transaction, Txid};
use lightning::chain::{BestBlock, Filter};

use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient};
Expand Down Expand Up @@ -459,6 +459,38 @@ impl ChainSource {
}
}
}

pub(crate) fn can_broadcast_transaction(&self, tx: &Transaction) -> Result<bool, Error> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
match &self.kind {
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
bitcoind_chain_source.can_broadcast_transaction(tx).await
},
ChainSourceKind::Esplora{..} => {
// Esplora doesn't support testmempoolaccept equivalent.
unreachable!("Mempool accept testing is not supported with Esplora backend. Use BitcoindRpc for this functionality.")
},
ChainSourceKind::Electrum{..} => {
// Electrum doesn't support testmempoolaccept equivalent.
unreachable!("Mempool accept testing is not supported with Electrum backend. Use BitcoindRpc for this functionality.")
},
}
})
})
}

pub(crate) fn get_transaction(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
match &self.kind {
ChainSourceKind::Bitcoind(bitcoind) => bitcoind.get_transaction(txid).await,
ChainSourceKind::Esplora(esplora) => esplora.get_transaction(txid).await,
ChainSourceKind::Electrum(electrum) => electrum.get_transaction(txid).await,
}
})
})
}
}

impl Filter for ChainSource {
Expand Down
12 changes: 11 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use lightning::routing::router::RouteParametersConfig;
use lightning::util::config::{
ChannelConfig as LdkChannelConfig, MaxDustHTLCExposure as LdkMaxDustHTLCExposure, UserConfig,
};
use bitreq::URL;

use crate::logger::LogLevel;

Expand Down Expand Up @@ -127,7 +128,8 @@ pub(crate) const HRN_RESOLUTION_TIMEOUT_SECS: u64 = 5;
/// | `probing_liquidity_limit_multiplier` | 3 |
/// | `log_level` | Debug |
/// | `anchor_channels_config` | Some(..) |
/// | `route_parameters` | None |
/// | `route_parameters` | None |
/// | `payjoin_config` | None |
///
/// See [`AnchorChannelsConfig`] and [`RouteParametersConfig`] for more information regarding their
/// respective default values.
Expand Down Expand Up @@ -192,6 +194,7 @@ pub struct Config {
/// **Note:** If unset, default parameters will be used, and you will be able to override the
/// parameters on a per-payment basis in the corresponding method calls.
pub route_parameters: Option<RouteParametersConfig>,
pub payjoin_config: Option<PayjoinConfig>,
}

impl Default for Config {
Expand All @@ -206,6 +209,7 @@ impl Default for Config {
anchor_channels_config: Some(AnchorChannelsConfig::default()),
route_parameters: None,
node_alias: None,
payjoin_config: None,
}
}
}
Expand Down Expand Up @@ -561,6 +565,12 @@ pub enum AsyncPaymentsRole {
Server,
}

#[derive(Debug, Clone)]
pub struct PayjoinConfig {
pub payjoin_directory: URL,
pub ohttp_relay: URL,
}

#[cfg(test)]
mod tests {
use std::str::FromStr;
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ pub enum Error {
AsyncPaymentServicesDisabled,
/// Parsing a Human-Readable Name has failed.
HrnParsingFailed,
/// A transaction broadcast operation failed.
TxBroadcastFailed,
}

impl fmt::Display for Error {
Expand Down Expand Up @@ -213,6 +215,7 @@ impl fmt::Display for Error {
Self::HrnParsingFailed => {
write!(f, "Failed to parse a human-readable name.")
},
Self::TxBroadcastFailed => write!(f, "Failed to broadcast transaction."),
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/io/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@ pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer";
///
/// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice
pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices";

/// The payjoin sessions will be persisted under this key.
pub(crate) const PAYJOIN_SESSION_STORE_PRIMARY_NAMESPACE: &str = "payjoin_sessions";
pub(crate) const PAYJOIN_SESSION_STORE_SECONDARY_NAMESPACE: &str = "";
Loading
Loading