bdkffi/
kyoto.rs

1use bdk_kyoto::bip157;
2use bdk_kyoto::bip157::lookup_host;
3use bdk_kyoto::bip157::tokio;
4use bdk_kyoto::bip157::AddrV2;
5use bdk_kyoto::bip157::Network;
6use bdk_kyoto::bip157::Node;
7use bdk_kyoto::bip157::ServiceFlags;
8use bdk_kyoto::builder::Builder as BDKCbfBuilder;
9use bdk_kyoto::builder::BuilderExt;
10use bdk_kyoto::HashCheckpoint;
11use bdk_kyoto::Receiver;
12use bdk_kyoto::RejectReason;
13use bdk_kyoto::Requester;
14use bdk_kyoto::TrustedPeer;
15use bdk_kyoto::UnboundedReceiver;
16use bdk_kyoto::UpdateSubscriber;
17use bdk_kyoto::Warning as Warn;
18
19use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
20use std::path::PathBuf;
21use std::sync::Arc;
22use std::time::Duration;
23
24use tokio::sync::Mutex;
25
26use crate::bitcoin::BlockHash;
27use crate::bitcoin::Transaction;
28use crate::bitcoin::Wtxid;
29use crate::error::CbfError;
30use crate::types::BlockId;
31use crate::types::Update;
32use crate::wallet::Wallet;
33use crate::FeeRate;
34
35const DEFAULT_CONNECTIONS: u8 = 2;
36const CWD_PATH: &str = ".";
37const TCP_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
38const MESSAGE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
39
40/// Receive a [`CbfClient`] and [`CbfNode`].
41#[derive(Debug, uniffi::Record)]
42pub struct CbfComponents {
43    /// Publish events to the node, like broadcasting transactions or adding scripts.
44    pub client: Arc<CbfClient>,
45    /// The node to run and fetch transactions for a [`Wallet`].
46    pub node: Arc<CbfNode>,
47}
48
49/// A [`CbfClient`] handles wallet updates from a [`CbfNode`].
50#[derive(Debug, uniffi::Object)]
51pub struct CbfClient {
52    sender: Arc<Requester>,
53    info_rx: Mutex<Receiver<bdk_kyoto::Info>>,
54    warning_rx: Mutex<UnboundedReceiver<bdk_kyoto::Warning>>,
55    update_rx: Mutex<UpdateSubscriber<bdk_kyoto::wallets::Single>>,
56}
57
58/// A [`CbfNode`] gathers transactions for a [`Wallet`].
59/// To receive [`Update`] for [`Wallet`], refer to the
60/// [`CbfClient`]. The [`CbfNode`] will run until instructed
61/// to stop.
62#[derive(Debug, uniffi::Object)]
63pub struct CbfNode {
64    node: std::sync::Mutex<Option<Node>>,
65}
66
67#[uniffi::export]
68impl CbfNode {
69    /// Start the node on a detached OS thread and immediately return.
70    /// Subsequent calls have no effect.
71    pub fn run(self: Arc<Self>) {
72        let mut lock = self.node.lock().unwrap();
73        let Some(node) = lock.take() else {
74            return;
75        };
76        std::thread::spawn(|| {
77            tokio::runtime::Builder::new_multi_thread()
78                .enable_all()
79                .build()
80                .unwrap()
81                .block_on(async move {
82                    let _ = node.run().await;
83                })
84        });
85    }
86}
87
88/// Build a BIP 157/158 light client to fetch transactions for a `Wallet`.
89///
90/// Options:
91/// * List of `Peer`: Bitcoin full-nodes for the light client to connect to. May be empty.
92/// * `connections`: The number of connections for the light client to maintain.
93/// * `scan_type`: Sync, recover, or start a new wallet. For more information see [`ScanType`].
94/// * `data_dir`: Optional directory to store block headers and peers.
95///
96/// A note on recovering wallets. Developers should allow users to provide an
97/// approximate recovery height and an estimated number of transactions for the
98/// wallet. When determining how many scripts to check filters for, the `Wallet`
99/// `lookahead` value will be used. To ensure all transactions are recovered, the
100/// `lookahead` should be roughly the number of transactions in the wallet history.
101#[derive(Clone, uniffi::Object)]
102pub struct CbfBuilder {
103    connections: u8,
104    handshake_timeout: Duration,
105    response_timeout: Duration,
106    data_dir: Option<String>,
107    scan_type: ScanType,
108    socks5_proxy: Option<Socks5Proxy>,
109    peers: Vec<Peer>,
110    whitelist_only: bool,
111}
112
113#[allow(clippy::new_without_default)]
114#[uniffi::export]
115impl CbfBuilder {
116    /// Start a new [`CbfBuilder`]
117    #[uniffi::constructor]
118    pub fn new() -> Self {
119        CbfBuilder {
120            connections: DEFAULT_CONNECTIONS,
121            handshake_timeout: TCP_HANDSHAKE_TIMEOUT,
122            response_timeout: MESSAGE_RESPONSE_TIMEOUT,
123            data_dir: None,
124            scan_type: ScanType::default(),
125            socks5_proxy: None,
126            peers: Vec::new(),
127            whitelist_only: false,
128        }
129    }
130
131    /// The number of connections for the light client to maintain. Default is two.
132    pub fn connections(&self, connections: u8) -> Arc<Self> {
133        Arc::new(CbfBuilder {
134            connections,
135            ..self.clone()
136        })
137    }
138
139    /// Directory to store block headers and peers. If none is provided, the current
140    /// working directory will be used.
141    pub fn data_dir(&self, data_dir: String) -> Arc<Self> {
142        Arc::new(CbfBuilder {
143            data_dir: Some(data_dir),
144            ..self.clone()
145        })
146    }
147
148    /// Select between syncing, recovering, or scanning for new wallets.
149    pub fn scan_type(&self, scan_type: ScanType) -> Arc<Self> {
150        Arc::new(CbfBuilder {
151            scan_type,
152            ..self.clone()
153        })
154    }
155
156    /// Bitcoin full-nodes to attempt a connection with.
157    pub fn peers(&self, peers: Vec<Peer>) -> Arc<Self> {
158        Arc::new(CbfBuilder {
159            peers,
160            ..self.clone()
161        })
162    }
163
164    /// Configure the time in milliseconds that a node has to:
165    /// 1. Respond to the initial connection
166    /// 2. Respond to a request
167    pub fn configure_timeout_millis(&self, handshake: u64, response: u64) -> Arc<Self> {
168        Arc::new(CbfBuilder {
169            handshake_timeout: Duration::from_millis(handshake),
170            response_timeout: Duration::from_millis(response),
171            ..self.clone()
172        })
173    }
174
175    /// Configure connections to be established through a `Socks5 proxy. The vast majority of the
176    /// time, the connection is to a local Tor daemon, which is typically exposed at
177    /// `127.0.0.1:9050`.
178    pub fn socks5_proxy(&self, proxy: Socks5Proxy) -> Arc<Self> {
179        Arc::new(CbfBuilder {
180            socks5_proxy: Some(proxy),
181            ..self.clone()
182        })
183    }
184
185    /// When set, only connect to peers configured at build time. This will skip DNS and will not
186    /// use gossiped nodes.
187    pub fn only_configured_peers(&self) -> Arc<Self> {
188        Arc::new(CbfBuilder {
189            whitelist_only: true,
190            ..self.clone()
191        })
192    }
193
194    /// Construct a [`CbfComponents`] for a [`Wallet`].
195    pub fn build(&self, wallet: &Wallet) -> CbfComponents {
196        let wallet = wallet.get_wallet();
197
198        let mut trusted_peers = Vec::new();
199        for peer in self.peers.iter() {
200            trusted_peers.push(peer.clone().into());
201        }
202
203        let scan_type = match self.scan_type.clone() {
204            ScanType::Sync => bdk_kyoto::ScanType::Sync,
205            ScanType::Recovery {
206                used_script_index,
207                checkpoint,
208            } => {
209                let network = wallet.network();
210                match checkpoint {
211                    RecoveryPoint::GenesisBlock => bdk_kyoto::ScanType::Recovery {
212                        used_script_index,
213                        checkpoint: HashCheckpoint::from_genesis(network),
214                    },
215                    RecoveryPoint::SegwitActivation if matches!(network, Network::Bitcoin) => {
216                        bdk_kyoto::ScanType::Recovery {
217                            used_script_index,
218                            checkpoint: HashCheckpoint::segwit_activation(),
219                        }
220                    }
221                    RecoveryPoint::TaprootActivation if matches!(network, Network::Bitcoin) => {
222                        bdk_kyoto::ScanType::Recovery {
223                            used_script_index,
224                            checkpoint: HashCheckpoint::taproot_activation(),
225                        }
226                    }
227                    RecoveryPoint::SegwitActivation | RecoveryPoint::TaprootActivation => {
228                        bdk_kyoto::ScanType::Recovery {
229                            used_script_index,
230                            checkpoint: HashCheckpoint::from_genesis(network),
231                        }
232                    }
233                    RecoveryPoint::Other { birthday } => bdk_kyoto::ScanType::Recovery {
234                        used_script_index,
235                        checkpoint: HashCheckpoint::new(birthday.height, birthday.hash.0),
236                    },
237                }
238            }
239        };
240
241        let path_buf = self
242            .data_dir
243            .clone()
244            .map(|path| PathBuf::from(&path))
245            .unwrap_or(PathBuf::from(CWD_PATH));
246
247        let mut builder = BDKCbfBuilder::new(wallet.network())
248            .required_peers(self.connections)
249            .data_dir(path_buf)
250            .handshake_timeout(self.handshake_timeout)
251            .response_timeout(self.response_timeout)
252            .add_peers(trusted_peers);
253
254        if let Some(proxy) = &self.socks5_proxy {
255            let port = proxy.port;
256            let addr = proxy.address.inner;
257            builder = builder.socks5_proxy(SocketAddr::new(addr, port));
258        }
259
260        if self.whitelist_only {
261            builder = builder.whitelist_only();
262        }
263
264        let (client, logging, update_subscriber) = builder
265            .build_with_wallet(&wallet, scan_type)
266            .expect("networks match by definition")
267            .subscribe();
268        let (client, node) = client.managed_start();
269        let requester = client.requester();
270
271        let node = CbfNode {
272            node: std::sync::Mutex::new(Some(node)),
273        };
274
275        let client = CbfClient {
276            sender: Arc::new(requester),
277            info_rx: Mutex::new(logging.info_subscriber),
278            warning_rx: Mutex::new(logging.warning_subscriber),
279            update_rx: Mutex::new(update_subscriber),
280        };
281
282        CbfComponents {
283            client: Arc::new(client),
284            node: Arc::new(node),
285        }
286    }
287}
288
289#[uniffi::export]
290impl CbfClient {
291    /// Return the next available info message from a node. If none is returned, the node has stopped.
292    pub async fn next_info(&self) -> Result<Info, CbfError> {
293        let mut info_rx = self.info_rx.lock().await;
294        info_rx
295            .recv()
296            .await
297            .map(|e| e.into())
298            .ok_or(CbfError::NodeStopped)
299    }
300
301    /// Return the next available warning message from a node. If none is returned, the node has stopped.
302    pub async fn next_warning(&self) -> Result<Warning, CbfError> {
303        let mut warn_rx = self.warning_rx.lock().await;
304        warn_rx
305            .recv()
306            .await
307            .map(|warn| warn.into())
308            .ok_or(CbfError::NodeStopped)
309    }
310
311    /// Return an [`Update`]. This is method returns once the node syncs to the rest of
312    /// the network or a new block has been gossiped.
313    pub async fn update(&self) -> Result<Update, CbfError> {
314        let update = self
315            .update_rx
316            .lock()
317            .await
318            .update()
319            .await
320            .map_err(|_| CbfError::NodeStopped)?;
321        Ok(Update(update))
322    }
323
324    /// Broadcast a transaction to the network, erroring if the node has stopped running.
325    pub async fn broadcast(&self, transaction: &Transaction) -> Result<Arc<Wtxid>, CbfError> {
326        let tx: bip157::Transaction = transaction.into();
327        self.sender
328            .submit_package(tx)
329            .await
330            .map_err(From::from)
331            .map(|wtxid| Arc::new(Wtxid(wtxid)))
332    }
333
334    /// The minimum fee rate required to broadcast a transcation to all connected peers.
335    pub async fn min_broadcast_feerate(&self) -> Result<Arc<FeeRate>, CbfError> {
336        self.sender
337            .broadcast_min_feerate()
338            .await
339            .map_err(|_| CbfError::NodeStopped)
340            .map(|fee| Arc::new(FeeRate(fee)))
341    }
342
343    /// Fetch the average fee rate for a block by requesting it from a peer. Not recommend for
344    /// resource-limited devices.
345    pub async fn average_fee_rate(
346        &self,
347        blockhash: Arc<BlockHash>,
348    ) -> Result<Arc<FeeRate>, CbfError> {
349        let fee_rate = self
350            .sender
351            .average_fee_rate(blockhash.0)
352            .await
353            .map_err(|_| CbfError::NodeStopped)?;
354        Ok(Arc::new(fee_rate.into()))
355    }
356
357    /// Add another [`Peer`] to attempt a connection with.
358    pub fn connect(&self, peer: Peer) -> Result<(), CbfError> {
359        self.sender
360            .add_peer(peer)
361            .map_err(|_| CbfError::NodeStopped)
362    }
363
364    /// Query a Bitcoin DNS seeder using the configured resolver.
365    ///
366    /// This is **not** a generic DNS implementation. Host names are prefixed with a `x849` to filter
367    /// for compact block filter nodes from the seeder. For example `dns.myseeder.com` will be queried
368    /// as `x849.dns.myseeder.com`. This has no guarantee to return any `IpAddr`.
369    pub fn lookup_host(&self, hostname: String) -> Vec<Arc<IpAddress>> {
370        let node_handle = std::thread::spawn(move || {
371            tokio::runtime::Builder::new_current_thread()
372                .enable_all()
373                .build()
374                .unwrap()
375                .block_on(lookup_host(hostname))
376        });
377        let nodes = node_handle.join().unwrap_or_default();
378        nodes
379            .into_iter()
380            .map(|ip| Arc::new(IpAddress { inner: ip }))
381            .collect()
382    }
383
384    /// Get the list of current connections.
385    pub async fn peer_info(&self) -> Result<Vec<Arc<IpAddress>>, CbfError> {
386        let peers = self
387            .sender
388            .peer_info()
389            .await
390            .map_err(|_| CbfError::NodeStopped)?;
391        Ok(peers
392            .into_iter()
393            .filter_map(|(ip, _)| match ip {
394                AddrV2::Ipv4(ip) => Some(IpAddr::V4(ip)),
395                AddrV2::Ipv6(ip) => Some(IpAddr::V6(ip)),
396                _ => None,
397            })
398            .map(|ip| Arc::new(IpAddress { inner: ip }))
399            .collect())
400    }
401
402    /// Check if the node is still running in the background.
403    pub fn is_running(&self) -> bool {
404        self.sender.is_running()
405    }
406
407    /// Stop the [`CbfNode`]. Errors if the node is already stopped.
408    pub fn shutdown(&self) -> Result<(), CbfError> {
409        self.sender.shutdown().map_err(From::from)
410    }
411}
412
413/// A log message from the node.
414#[derive(Debug, uniffi::Enum)]
415pub enum Info {
416    /// All the required connections have been met. This is subject to change.
417    ConnectionsMet,
418    /// The node was able to successfully connect to a remote peer.
419    SuccessfulHandshake,
420    /// A percentage value of filters that have been scanned.
421    Progress {
422        /// The height of the local block chain.
423        chain_height: u32,
424        /// The percent of filters downloaded.
425        filters_downloaded_percent: f32,
426    },
427    /// A relevant block was downloaded from a peer.
428    BlockReceived(String),
429}
430
431impl From<bdk_kyoto::Info> for Info {
432    fn from(value: bdk_kyoto::Info) -> Info {
433        match value {
434            bdk_kyoto::Info::ConnectionsMet => Info::ConnectionsMet,
435            bdk_kyoto::Info::SuccessfulHandshake => Info::SuccessfulHandshake,
436            bdk_kyoto::Info::Progress(progress) => Info::Progress {
437                filters_downloaded_percent: progress.percentage_complete(),
438                chain_height: progress.chain_height(),
439            },
440            bdk_kyoto::Info::BlockReceived(block) => Info::BlockReceived(block.to_string()),
441        }
442    }
443}
444
445/// Warnings a node may issue while running.
446#[derive(Debug, uniffi::Enum)]
447pub enum Warning {
448    /// The node is looking for connections to peers.
449    NeedConnections,
450    /// A connection to a peer timed out.
451    PeerTimedOut,
452    /// The node was unable to connect to a peer in the database.
453    CouldNotConnect,
454    /// A connection was maintained, but the peer does not signal for compact block filers.
455    NoCompactFilters,
456    /// The node has been waiting for new inv and will find new peers to avoid block withholding.
457    PotentialStaleTip,
458    /// A peer sent us a peer-to-peer message the node did not request.
459    UnsolicitedMessage,
460    /// A transaction got rejected, likely for being an insufficient fee or non-standard transaction.
461    TransactionRejected {
462        wtxid: String,
463        reason: Option<String>,
464    },
465    /// The peer sent us a potential fork.
466    EvaluatingFork,
467    /// An unexpected error occurred processing a peer-to-peer message.
468    UnexpectedSyncError { warning: String },
469    /// The node failed to respond to a message sent from the client.
470    RequestFailed,
471}
472
473impl From<Warn> for Warning {
474    fn from(value: Warn) -> Warning {
475        match value {
476            Warn::NeedConnections {
477                connected: _,
478                required: _,
479            } => Warning::NeedConnections,
480            Warn::PeerTimedOut => Warning::PeerTimedOut,
481            Warn::CouldNotConnect => Warning::CouldNotConnect,
482            Warn::NoCompactFilters => Warning::NoCompactFilters,
483            Warn::PotentialStaleTip => Warning::PotentialStaleTip,
484            Warn::UnsolicitedMessage => Warning::UnsolicitedMessage,
485            Warn::TransactionRejected { payload } => {
486                let reason = payload.reason.map(|r| r.into_string());
487                Warning::TransactionRejected {
488                    wtxid: payload.wtxid.to_string(),
489                    reason,
490                }
491            }
492            Warn::EvaluatingFork => Warning::EvaluatingFork,
493            Warn::UnexpectedSyncError { warning } => Warning::UnexpectedSyncError { warning },
494            Warn::ChannelDropped => Warning::RequestFailed,
495        }
496    }
497}
498
499/// Sync a wallet from the last known block hash or recover a wallet from a specified recovery
500/// point.
501#[derive(Debug, Clone, Default, uniffi::Enum)]
502pub enum ScanType {
503    /// Sync an existing wallet from the last stored chain checkpoint.
504    #[default]
505    Sync,
506    /// Recover an existing wallet by scanning from the specified height.
507    Recovery {
508        /// The estimated number of scripts the user has revealed for the wallet being recovered.
509        /// If unknown, a conservative estimate, say 1,000, could be used.
510        used_script_index: u32,
511        /// A relevant starting point or soft fork to start the sync.
512        checkpoint: RecoveryPoint,
513    },
514}
515
516#[derive(Debug, Clone, Default, uniffi::Enum)]
517pub enum RecoveryPoint {
518    GenesisBlock,
519    #[default]
520    SegwitActivation,
521    TaprootActivation,
522    Other {
523        birthday: BlockId,
524    },
525}
526
527/// A peer to connect to over the Bitcoin peer-to-peer network.
528#[derive(Clone, uniffi::Record)]
529pub struct Peer {
530    /// The IP address to reach the node.
531    pub address: Arc<IpAddress>,
532    /// The port to reach the node. If none is provided, the default
533    /// port for the selected network will be used.
534    pub port: Option<u16>,
535    /// Does the remote node offer encrypted peer-to-peer connection.
536    pub v2_transport: bool,
537}
538
539/// An IP address to connect to over TCP.
540#[derive(Debug, uniffi::Object)]
541#[uniffi::export(Display)]
542pub struct IpAddress {
543    inner: IpAddr,
544}
545
546impl core::fmt::Display for IpAddress {
547    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
548        write!(f, "{}", self.inner)
549    }
550}
551
552#[uniffi::export]
553impl IpAddress {
554    /// Build an IPv4 address.
555    #[uniffi::constructor]
556    pub fn from_ipv4(q1: u8, q2: u8, q3: u8, q4: u8) -> Self {
557        Self {
558            inner: IpAddr::V4(Ipv4Addr::new(q1, q2, q3, q4)),
559        }
560    }
561
562    /// Build an IPv6 address.
563    #[allow(clippy::too_many_arguments)]
564    #[uniffi::constructor]
565    pub fn from_ipv6(a: u16, b: u16, c: u16, d: u16, e: u16, f: u16, g: u16, h: u16) -> Self {
566        Self {
567            inner: IpAddr::V6(Ipv6Addr::new(a, b, c, d, e, f, g, h)),
568        }
569    }
570}
571
572/// A proxy to route network traffic, most likely through a Tor daemon. Normally this proxy is
573/// exposed at 127.0.0.1:9050.
574#[derive(Debug, Clone, uniffi::Record)]
575pub struct Socks5Proxy {
576    /// The IP address, likely `127.0.0.1`
577    pub address: Arc<IpAddress>,
578    /// The listening port, likely `9050`
579    pub port: u16,
580}
581
582impl From<Peer> for TrustedPeer {
583    fn from(peer: Peer) -> Self {
584        let services = if peer.v2_transport {
585            let mut services = ServiceFlags::P2P_V2;
586            services.add(ServiceFlags::NETWORK);
587            services.add(ServiceFlags::COMPACT_FILTERS);
588            services
589        } else {
590            let mut services = ServiceFlags::COMPACT_FILTERS;
591            services.add(ServiceFlags::NETWORK);
592            services
593        };
594        let addr_v2 = match peer.address.inner {
595            IpAddr::V4(ipv4_addr) => AddrV2::Ipv4(ipv4_addr),
596            IpAddr::V6(ipv6_addr) => AddrV2::Ipv6(ipv6_addr),
597        };
598        TrustedPeer::new(addr_v2, peer.port, services)
599    }
600}
601
602trait DisplayExt {
603    fn into_string(self) -> String;
604}
605
606impl DisplayExt for RejectReason {
607    fn into_string(self) -> String {
608        let message = match self {
609            RejectReason::Malformed => "Message could not be decoded.",
610            RejectReason::Invalid => "Transaction was invalid for some reason.",
611            RejectReason::Obsolete => "Client version is no longer supported.",
612            RejectReason::Duplicate => "Duplicate version message received.",
613            RejectReason::NonStandard => "Transaction was nonstandard.",
614            RejectReason::Dust => "One or more outputs are below the dust threshold.",
615            RejectReason::Fee => "Transaction does not have enough fee to be mined.",
616            RejectReason::Checkpoint => "Inconsistent with compiled checkpoint.",
617        };
618        message.into()
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::CbfNode;
625    use std::sync::{Arc, Mutex};
626
627    #[test]
628    fn running_a_consumed_node_is_a_no_op() {
629        let node = Arc::new(CbfNode {
630            node: Mutex::new(None),
631        });
632
633        node.run();
634    }
635}