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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ prost = { version = "0.11.6", default-features = false, optional = true}
#bitcoin-payment-instructions = { version = "0.6" }
bitcoin-payment-instructions = { git = "https://github.com/benthecarman/bitcoin-payment-instructions", rev = "3dbe11bdf8916639df42476501fb40f87b253e84", optional = true }

[patch.crates-io]
esplora-client-012 = { package = "esplora-client", path = "vendor/esplora-client-0.12.3" }
esplora-client-013 = { package = "esplora-client", path = "vendor/esplora-client-0.13.0" }

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

Expand Down
97 changes: 93 additions & 4 deletions src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ use crate::{Node, NodeMetrics, PersistedNodeMetrics};
const LSPS_HARDENED_CHILD_INDEX: u32 = 577;
const PERSISTER_MAX_PENDING_UPDATES: u64 = 100;

#[derive(Debug, Clone)]
#[derive(Clone)]
enum ChainDataSourceConfig {
#[cfg(feature = "chain-esplora")]
Esplora {
server_url: String,
headers: HashMap<String, String>,
header_provider: Option<Arc<dyn crate::EsploraHeaderProvider>>,
sync_config: Option<EsploraSyncConfig>,
},
#[cfg(feature = "chain-electrum")]
Expand All @@ -141,6 +142,44 @@ enum ChainDataSourceConfig {
},
}

impl std::fmt::Debug for ChainDataSourceConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(feature = "chain-esplora")]
Self::Esplora { server_url, headers, header_provider, sync_config } => f
.debug_struct("Esplora")
.field("server_url", server_url)
.field("headers", headers)
.field("has_header_provider", &header_provider.is_some())
.field("sync_config", sync_config)
.finish(),
#[cfg(feature = "chain-electrum")]
Self::Electrum { server_url, sync_config } => f
.debug_struct("Electrum")
.field("server_url", server_url)
.field("sync_config", sync_config)
.finish(),
#[cfg(feature = "chain-bitcoind")]
Self::Bitcoind {
rpc_host,
rpc_port,
rpc_user,
rpc_password: _,
rest_client_config,
wallet_rescan_from_height,
} => f
.debug_struct("Bitcoind")
.field("rpc_host", rpc_host)
.field("rpc_port", rpc_port)
.field("rpc_user", rpc_user)
.field("rpc_password", &"[REDACTED]")
.field("rest_client_config", rest_client_config)
.field("wallet_rescan_from_height", wallet_rescan_from_height)
.finish(),
}
}
}

#[derive(Debug, Clone)]
enum GossipSourceConfig {
P2PNetwork,
Expand Down Expand Up @@ -405,6 +444,7 @@ impl NodeBuilder {
self.chain_data_source_config = Some(ChainDataSourceConfig::Esplora {
server_url,
headers: Default::default(),
header_provider: None,
sync_config,
});
self
Expand All @@ -422,8 +462,34 @@ impl NodeBuilder {
&mut self, server_url: String, headers: HashMap<String, String>,
sync_config: Option<EsploraSyncConfig>,
) -> &mut Self {
self.chain_data_source_config =
Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config });
self.chain_data_source_config = Some(ChainDataSourceConfig::Esplora {
server_url,
headers,
header_provider: None,
sync_config,
});
self
}

/// Configures Esplora requests to obtain headers from `header_provider` at request time.
///
/// The provider is shared by wallet synchronization and LDK transaction synchronization. It is
/// called for every HTTP request, allowing applications to refresh short-lived credentials
/// while the node is running. Provider errors fail the corresponding Esplora request.
///
/// If no `sync_config` is given, default values are used. See [`EsploraSyncConfig`] for more
/// information.
#[cfg(feature = "chain-esplora")]
pub fn set_chain_source_esplora_with_header_provider(
&mut self, server_url: String, header_provider: Arc<dyn crate::EsploraHeaderProvider>,
sync_config: Option<EsploraSyncConfig>,
) -> &mut Self {
self.chain_data_source_config = Some(ChainDataSourceConfig::Esplora {
server_url,
headers: Default::default(),
header_provider: Some(header_provider),
sync_config,
});
self
}

Expand Down Expand Up @@ -1064,6 +1130,22 @@ impl Builder {
) {
self.inner.write().expect("lock").set_chain_source_esplora(server_url, sync_config);
}

/// Configures Esplora requests to obtain headers from `header_provider` at request time.
///
/// The provider is shared by wallet synchronization and LDK transaction synchronization and is
/// called for every HTTP request. Provider errors fail the corresponding request.
pub fn set_chain_source_esplora_with_header_provider(
&self, server_url: String, header_provider: Arc<dyn crate::ffi::EsploraHeaderProvider>,
sync_config: Option<EsploraSyncConfig>,
) {
let adapter = Arc::new(crate::ffi::EsploraHeaderProviderAdapter::new(header_provider));
self.inner.write().expect("lock").set_chain_source_esplora_with_header_provider(
server_url,
adapter,
sync_config,
);
}
}

#[cfg(all(feature = "uniffi", feature = "chain-esplora"))]
Expand Down Expand Up @@ -1645,11 +1727,17 @@ fn build_with_store_internal(

let (chain_source, chain_tip_opt) = match chain_data_source_config {
#[cfg(feature = "chain-esplora")]
Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => {
Some(ChainDataSourceConfig::Esplora {
server_url,
headers,
header_provider,
sync_config,
}) => {
let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default());
ChainSource::new_esplora(
server_url.clone(),
headers.clone(),
header_provider.clone(),
sync_config,
Arc::clone(&fee_estimator),
Arc::clone(&tx_broadcaster),
Expand Down Expand Up @@ -1724,6 +1812,7 @@ fn build_with_store_internal(
ChainSource::new_esplora(
server_url.clone(),
HashMap::new(),
None,
sync_config,
Arc::clone(&fee_estimator),
Arc::clone(&tx_broadcaster),
Expand Down
116 changes: 113 additions & 3 deletions src/chain/esplora.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

/// Supplies current HTTP headers for each Esplora request.
///
/// Implementations can refresh short-lived credentials while the node is running. The provider is
/// called separately by both Esplora clients and its errors fail the corresponding request.
#[async_trait::async_trait]
pub trait EsploraHeaderProvider: Send + Sync {
/// Return the headers to attach to the next Esplora request.
async fn get_headers(&self) -> Result<HashMap<String, String>, String>;
}

fn to_client_header_provider(
provider: Arc<dyn EsploraHeaderProvider>,
) -> esplora_client::HeaderProvider {
Arc::new(move || {
let provider = Arc::clone(&provider);
Box::pin(async move { provider.get_headers().await })
})
}

use bdk_esplora::EsploraAsyncExt;
use bitcoin::transaction::Version;
use bitcoin::{FeeRate, Network, Script, Txid};
Expand Down Expand Up @@ -49,7 +68,8 @@ pub(super) struct EsploraChainSource {

impl EsploraChainSource {
pub(crate) fn new(
server_url: String, headers: HashMap<String, String>, sync_config: EsploraSyncConfig,
server_url: String, headers: HashMap<String, String>,
header_provider: Option<Arc<dyn EsploraHeaderProvider>>, sync_config: EsploraSyncConfig,
fee_estimator: Arc<OnchainFeeEstimator>, kv_store: Arc<DynStore>, config: Arc<Config>,
logger: Arc<Logger>, node_metrics: Arc<PersistedNodeMetrics>,
) -> Result<Self, ()> {
Expand All @@ -66,10 +86,21 @@ impl EsploraChainSource {
ldk_client_builder = ldk_client_builder.header(header_name, header_value);
}

let esplora_client = client_builder.build_async().map_err(|e| {
let esplora_client = if let Some(provider) = header_provider.as_ref() {
client_builder
.build_async_with_header_provider(to_client_header_provider(Arc::clone(provider)))
} else {
client_builder.build_async()
}
.map_err(|e| {
log_error!(logger, "Failed to build Esplora client: {}", e);
})?;
let ldk_esplora_client = ldk_client_builder.build_async().map_err(|e| {
let ldk_esplora_client = if let Some(provider) = header_provider {
ldk_client_builder.build_async_with_header_provider(to_client_header_provider(provider))
} else {
ldk_client_builder.build_async()
}
.map_err(|e| {
log_error!(logger, "Failed to build LDK Esplora client: {}", e);
})?;
let tx_sync =
Expand Down Expand Up @@ -530,6 +561,85 @@ impl EsploraChainSource {
}
}

#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

use super::{to_client_header_provider, EsploraHeaderProvider};
use std::collections::HashMap;
use std::sync::Arc;

struct RotatingHeaderProvider {
next_token: AtomicUsize,
}

#[async_trait::async_trait]
impl EsploraHeaderProvider for RotatingHeaderProvider {
async fn get_headers(&self) -> Result<HashMap<String, String>, String> {
let token = self.next_token.fetch_add(1, Ordering::SeqCst) + 1;
Ok(HashMap::from([("Authorization".to_string(), format!("Bearer {token}"))]))
}
}

fn start_esplora_server(request_count: usize) -> (String, thread::JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let mut requests = Vec::with_capacity(request_count);
for _ in 0..request_count {
let (mut stream, _) = listener.accept().unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
loop {
let bytes_read = stream.read(&mut buffer).unwrap();
request.extend_from_slice(&buffer[..bytes_read]);
if bytes_read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
requests.push(String::from_utf8(request).unwrap());
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\n1",
)
.unwrap();
}
requests
});
(format!("http://{address}"), server)
}

#[tokio::test]
async fn dynamic_headers_refresh_for_both_esplora_clients() {
let (server_url, server) = start_esplora_server(2);
let provider: Arc<dyn EsploraHeaderProvider> =
Arc::new(RotatingHeaderProvider { next_token: AtomicUsize::new(0) });

let bdk_client = esplora_client::Builder::new(&server_url)
.build_async_with_header_provider(to_client_header_provider(Arc::clone(&provider)))
.unwrap();
assert_eq!(bdk_client.get_height().await.unwrap(), 1);

let ldk_client = ldk_esplora_client::Builder::new(&server_url)
.build_async_with_header_provider(to_client_header_provider(provider))
.unwrap();
assert_eq!(ldk_client.get_height().await.unwrap(), 1);

let requests = server.join().unwrap();
assert!(
requests[0].contains("authorization: Bearer 1\r\n")
|| requests[0].contains("Authorization: Bearer 1\r\n")
);
assert!(
requests[1].contains("authorization: Bearer 2\r\n")
|| requests[1].contains("Authorization: Bearer 2\r\n")
);
}
}

impl Filter for EsploraChainSource {
fn register_tx(&self, txid: &Txid, script_pubkey: &Script) {
self.tx_sync.register_tx(txid, script_pubkey);
Expand Down
12 changes: 8 additions & 4 deletions src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub(crate) mod bitcoind;
mod electrum;
#[cfg(feature = "chain-esplora")]
mod esplora;
#[cfg(feature = "chain-esplora")]
pub use self::esplora::EsploraHeaderProvider;

#[cfg(feature = "chain-esplora")]
use std::collections::HashMap;
Expand Down Expand Up @@ -225,14 +227,16 @@ impl Listen for ChainListener {
impl ChainSource {
#[cfg(feature = "chain-esplora")]
pub(crate) fn new_esplora(
server_url: String, headers: HashMap<String, String>, sync_config: EsploraSyncConfig,
fee_estimator: Arc<OnchainFeeEstimator>, tx_broadcaster: Arc<Broadcaster>,
kv_store: Arc<DynStore>, config: Arc<Config>, logger: Arc<Logger>,
node_metrics: Arc<PersistedNodeMetrics>,
server_url: String, headers: HashMap<String, String>,
header_provider: Option<Arc<dyn crate::chain::esplora::EsploraHeaderProvider>>,
sync_config: EsploraSyncConfig, fee_estimator: Arc<OnchainFeeEstimator>,
tx_broadcaster: Arc<Broadcaster>, kv_store: Arc<DynStore>, config: Arc<Config>,
logger: Arc<Logger>, node_metrics: Arc<PersistedNodeMetrics>,
) -> Result<(Self, Option<BlockLocator>), ()> {
let esplora_chain_source = EsploraChainSource::new(
server_url,
headers,
header_provider,
sync_config,
fee_estimator,
kv_store,
Expand Down
49 changes: 49 additions & 0 deletions src/ffi/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,55 @@ pub trait VssHeaderProvider: Send + Sync {
) -> Result<HashMap<String, String>, VssHeaderProviderError>;
}

/// Error returned when an Esplora header provider cannot supply headers.
#[derive(Debug, uniffi::Error)]
pub enum EsploraHeaderProviderError {
/// Providing headers failed.
ProviderError {
/// Description of the failure.
error: String,
},
}

impl std::fmt::Display for EsploraHeaderProviderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ProviderError { error } => write!(f, "Esplora header provider failed: {error}"),
}
}
}

impl std::error::Error for EsploraHeaderProviderError {}

/// Supplies current HTTP headers for each Esplora request from a foreign language.
///
/// This callback is synchronous because UniFFI 0.29 does not support async foreign traits. It
/// should return cached/current headers promptly; token acquisition and refresh can run separately.
#[uniffi::export(with_foreign)]
pub trait EsploraHeaderProvider: Send + Sync {
/// Returns the headers to use for the next Esplora request.
fn get_headers(&self) -> Result<HashMap<String, String>, EsploraHeaderProviderError>;
}

pub(crate) struct EsploraHeaderProviderAdapter {
inner: Arc<dyn EsploraHeaderProvider>,
}

impl EsploraHeaderProviderAdapter {
pub(crate) fn new(inner: Arc<dyn EsploraHeaderProvider>) -> Self {
Self { inner }
}
}

#[async_trait::async_trait]
impl crate::EsploraHeaderProvider for EsploraHeaderProviderAdapter {
async fn get_headers(&self) -> Result<HashMap<String, String>, String> {
self.inner.get_headers().map_err(|e| match e {
EsploraHeaderProviderError::ProviderError { error } => error,
})
}
}

/// An adapter that wraps the local [`VssHeaderProvider`] and implements the upstream
/// [`VssClientHeaderProvider`] trait.
#[cfg(feature = "storage-vss")]
Expand Down
Loading
Loading