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