diff --git a/backend-blindbit-v1/Cargo.toml b/backend-blindbit-v1/Cargo.toml index f3813be..427b64d 100644 --- a/backend-blindbit-v1/Cargo.toml +++ b/backend-blindbit-v1/Cargo.toml @@ -7,14 +7,19 @@ repository.workspace = true [lib] crate-type = ["lib", "staticlib", "cdylib"] +[features] +default = ["async"] +async = ["spdk-core/async", "dep:async-trait", "dep:futures", "dep:reqwest"] +sync = ["spdk-core/sync", "dep:ureq"] + [dependencies] -spdk-core.workspace = true +spdk-core = { workspace = true, default-features = false } anyhow.workspace = true -async-trait.workspace = true -bitcoin.workspace = true -futures.workspace = true +async-trait = { workspace = true, optional = true } +futures = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true -reqwest.workspace = true +reqwest = { workspace = true, optional = true } +ureq = { version = "2", features = ["json", "gzip", "tls"], optional = true } hex.workspace = true diff --git a/backend-blindbit-v1/src/lib.rs b/backend-blindbit-v1/src/lib.rs index dac46e7..81eeaee 100644 --- a/backend-blindbit-v1/src/lib.rs +++ b/backend-blindbit-v1/src/lib.rs @@ -1,6 +1,29 @@ +#[cfg(all(feature = "async", feature = "sync"))] +compile_error!( + "Features `async` and `sync` are mutually exclusive. Use `--no-default-features --features sync` for a sync build." +); + +#[cfg(not(any(feature = "async", feature = "sync")))] +compile_error!("Either feature `async` or `sync` must be enabled."); + pub mod api_structs; + +#[cfg(feature = "async")] mod backend; +#[cfg(feature = "async")] mod client; +#[cfg(feature = "sync")] +mod sync_backend; +#[cfg(feature = "sync")] +mod sync_client; + +#[cfg(feature = "async")] pub use backend::BlindbitBackend; +#[cfg(feature = "async")] pub use client::BlindbitClient; + +#[cfg(feature = "sync")] +pub use sync_backend::SyncBlindbitBackend; +#[cfg(feature = "sync")] +pub use sync_client::SyncBlindbitClient; diff --git a/backend-blindbit-v1/src/sync_backend.rs b/backend-blindbit-v1/src/sync_backend.rs new file mode 100644 index 0000000..ec9bf0e --- /dev/null +++ b/backend-blindbit-v1/src/sync_backend.rs @@ -0,0 +1,73 @@ +use std::ops::RangeInclusive; + +use spdk_core::bitcoin::{Amount, absolute::Height}; + +use anyhow::Result; +use spdk_core::{BlockData, SpentIndexData, SyncChainBackend, UtxoData}; + +use crate::SyncBlindbitClient; + +#[derive(Debug)] +pub struct SyncBlindbitBackend { + client: SyncBlindbitClient, +} + +impl SyncBlindbitBackend { + pub fn new(blindbit_url: String) -> Result { + Ok(Self { + client: SyncBlindbitClient::new(blindbit_url)?, + }) + } +} + +impl SyncChainBackend for SyncBlindbitBackend { + /// High-level function to get block data for a range of blocks. + /// Block data includes all the information needed to determine if a block is relevant for scanning, + /// but does not include utxos, or spent index. + /// These need to be fetched separately afterwards, if it is determined this block is relevant. + fn get_block_data_for_range( + &self, + range: RangeInclusive, + dust_limit: Amount, + with_cutthrough: bool, + ) -> Box> + Send> { + let client = self.client.clone(); + + let iter = range.map(move |n| { + let blkheight = Height::from_consensus(n)?; + let tweaks = match with_cutthrough { + true => client.tweaks(blkheight, dust_limit)?, + false => client.tweak_index(blkheight, dust_limit)?, + }; + let new_utxo_filter = client.filter_new_utxos(blkheight)?; + let spent_filter = client.filter_spent(blkheight)?; + let blkhash = new_utxo_filter.block_hash; + Ok(BlockData { + blkheight, + blkhash, + tweaks, + new_utxo_filter: new_utxo_filter.into(), + spent_filter: spent_filter.into(), + }) + }); + + Box::new(iter) + } + + fn spent_index(&self, block_height: Height) -> Result { + self.client.spent_index(block_height).map(Into::into) + } + + fn utxos(&self, block_height: Height) -> Result> { + Ok(self + .client + .utxos(block_height)? + .into_iter() + .map(Into::into) + .collect()) + } + + fn block_height(&self) -> Result { + self.client.block_height() + } +} diff --git a/backend-blindbit-v1/src/sync_client.rs b/backend-blindbit-v1/src/sync_client.rs new file mode 100644 index 0000000..e87f6bf --- /dev/null +++ b/backend-blindbit-v1/src/sync_client.rs @@ -0,0 +1,105 @@ +use std::time::Duration; + +use spdk_core::bitcoin::{Amount, Txid, absolute::Height, secp256k1::PublicKey}; +use ureq::Agent; + +use anyhow::Result; + +use super::api_structs::{ + BlockHeightResponse, FilterResponse, ForwardTxRequest, InfoResponse, SpentIndexResponse, + UtxoResponse, +}; + +#[derive(Clone, Debug)] +pub struct SyncBlindbitClient { + agent: Agent, + host_url: String, +} + +impl SyncBlindbitClient { + pub fn new(host_url: String) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(5)) + .timeout_read(Duration::from_secs(30)) + .build(); + + // we need a trailing slash, if not present we append it + let host_url = if host_url.ends_with('/') { + host_url + } else { + format!("{}/", host_url) + }; + + Ok(SyncBlindbitClient { agent, host_url }) + } + + pub fn block_height(&self) -> Result { + let url = format!("{}block-height", self.host_url); + let body = self.agent.get(&url).call()?.into_string()?; + let blkheight: BlockHeightResponse = serde_json::from_str(&body)?; + Ok(blkheight.block_height) + } + + pub fn tweaks(&self, block_height: Height, dust_limit: Amount) -> Result> { + let url = format!("{}tweaks/{}", self.host_url, block_height); + let body = self + .agent + .get(&url) + .query("dustLimit", &dust_limit.to_sat().to_string()) + .call()? + .into_string()?; + Ok(serde_json::from_str(&body)?) + } + + pub fn tweak_index(&self, block_height: Height, dust_limit: Amount) -> Result> { + let url = format!("{}tweak-index/{}", self.host_url, block_height); + let body = self + .agent + .get(&url) + .query("dustLimit", &dust_limit.to_sat().to_string()) + .call()? + .into_string()?; + Ok(serde_json::from_str(&body)?) + } + + pub fn utxos(&self, block_height: Height) -> Result> { + let url = format!("{}utxos/{}", self.host_url, block_height); + let body = self.agent.get(&url).call()?.into_string()?; + Ok(serde_json::from_str(&body)?) + } + + pub fn spent_index(&self, block_height: Height) -> Result { + let url = format!("{}spent-index/{}", self.host_url, block_height); + let body = self.agent.get(&url).call()?.into_string()?; + Ok(serde_json::from_str(&body)?) + } + + pub fn filter_new_utxos(&self, block_height: Height) -> Result { + let url = format!("{}filter/new-utxos/{}", self.host_url, block_height); + let body = self.agent.get(&url).call()?.into_string()?; + Ok(serde_json::from_str(&body)?) + } + + pub fn filter_spent(&self, block_height: Height) -> Result { + let url = format!("{}filter/spent/{}", self.host_url, block_height); + let body = self.agent.get(&url).call()?.into_string()?; + Ok(serde_json::from_str(&body)?) + } + + pub fn forward_tx(&self, tx_hex: String) -> Result { + let url = format!("{}forward-tx", self.host_url); + let body = ForwardTxRequest::new(tx_hex); + let resp = self + .agent + .post(&url) + .send_json(serde_json::to_value(&body)?)? + .into_string()?; + Ok(serde_json::from_str(&resp)?) + } + + pub fn info(&self) -> Result { + let url = format!("{}info", self.host_url); + let body = self.agent.get(&url).call()?.into_string()?; + Ok(serde_json::from_str(&body)?) + } +} diff --git a/spdk-core/Cargo.toml b/spdk-core/Cargo.toml index e6f4e83..9436197 100644 --- a/spdk-core/Cargo.toml +++ b/spdk-core/Cargo.toml @@ -7,10 +7,15 @@ repository.workspace = true [lib] crate-type = ["lib", "staticlib", "cdylib"] +[features] +default = ["async"] +async = ["dep:async-trait", "dep:futures"] +sync = [] + [dependencies] anyhow.workspace = true -async-trait.workspace = true +async-trait = { workspace = true, optional = true } bitcoin.workspace = true -futures.workspace = true +futures = { workspace = true, optional = true } serde.workspace = true silentpayments.workspace = true diff --git a/spdk-core/src/chain/mod.rs b/spdk-core/src/chain/mod.rs index afbb7db..cbaa3c4 100644 --- a/spdk-core/src/chain/mod.rs +++ b/spdk-core/src/chain/mod.rs @@ -1,5 +1,12 @@ mod structs; + +#[cfg(feature = "sync")] +mod sync_trait; +#[cfg(feature = "async")] mod r#trait; +#[cfg(feature = "async")] pub use r#trait::ChainBackend; pub use structs::*; +#[cfg(feature = "sync")] +pub use sync_trait::SyncChainBackend; diff --git a/spdk-core/src/chain/sync_trait.rs b/spdk-core/src/chain/sync_trait.rs new file mode 100644 index 0000000..6522093 --- /dev/null +++ b/spdk-core/src/chain/sync_trait.rs @@ -0,0 +1,21 @@ +use std::ops::RangeInclusive; + +use anyhow::Result; +use bitcoin::{absolute::Height, Amount}; + +use super::structs::{BlockData, SpentIndexData, UtxoData}; + +pub trait SyncChainBackend { + fn get_block_data_for_range( + &self, + range: RangeInclusive, + dust_limit: Amount, + with_cutthrough: bool, + ) -> Box> + Send>; + + fn spent_index(&self, block_height: Height) -> Result; + + fn utxos(&self, block_height: Height) -> Result>; + + fn block_height(&self) -> Result; +} diff --git a/spdk-core/src/lib.rs b/spdk-core/src/lib.rs index 3b7392c..03df7cf 100644 --- a/spdk-core/src/lib.rs +++ b/spdk-core/src/lib.rs @@ -1,3 +1,9 @@ +#[cfg(all(feature = "async", feature = "sync"))] +compile_error!("Features `async` and `sync` are mutually exclusive. Use `--no-default-features --features sync` for a sync build."); + +#[cfg(not(any(feature = "async", feature = "sync")))] +compile_error!("Either feature `async` or `sync` must be enabled."); + pub mod chain; pub mod constants; pub mod updater; diff --git a/spdk-wallet/src/scanner/logic.rs b/spdk-wallet/src/scanner/logic.rs new file mode 100644 index 0000000..58f62ae --- /dev/null +++ b/spdk-wallet/src/scanner/logic.rs @@ -0,0 +1,186 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::{Error, Result}; +use bitcoin::{ + BlockHash, OutPoint, Txid, XOnlyPublicKey, absolute::Height, bip158::BlockFilter, hashes::{Hash, sha256}, hex::DisplayHex, secp256k1::{PublicKey, Scalar} +}; +use silentpayments::receiving::{Label, Receiver}; + +use crate::{ + client::{OutputSpendStatus, OwnedOutput}, +}; + +use spdk_core::chain::UtxoData; + +/// Check if a block's created-UTXO filter matches any of our candidate scriptpubkeys. +pub(crate) fn check_block_outputs( + created_utxo_filter: BlockFilter, + blkhash: BlockHash, + candidate_spks: Vec<&[u8; 34]>, +) -> Result { + let output_keys: Vec<_> = candidate_spks + .into_iter() + .map(|spk| spk[2..].as_ref()) + .collect(); + + // note: match will always return true for an empty query! + if !output_keys.is_empty() { + Ok(created_utxo_filter.match_any(&blkhash, &mut output_keys.into_iter())?) + } else { + Ok(false) + } +} + +/// Compute 8-byte input hashes for our owned outpoints against a given block hash. +pub(crate) fn get_input_hashes( + owned_outpoints: &HashSet, + blkhash: BlockHash, +) -> Result> { + let mut map: HashMap<[u8; 8], OutPoint> = HashMap::new(); + + for outpoint in owned_outpoints { + let mut arr = [0u8; 68]; + arr[..32].copy_from_slice(&outpoint.txid.to_raw_hash().to_byte_array()); + arr[32..36].copy_from_slice(&outpoint.vout.to_le_bytes()); + arr[36..].copy_from_slice(&blkhash.to_byte_array()); + let hash = sha256::Hash::hash(&arr); + + let mut res = [0u8; 8]; + res.copy_from_slice(&hash[..8]); + + map.insert(res, *outpoint); + } + + Ok(map) +} + +/// Check if a block's spent filter matches any of our input hashes. +pub(crate) fn check_block_inputs( + spent_filter: BlockFilter, + blkhash: BlockHash, + input_hashes: Vec<[u8; 8]>, +) -> Result { + // note: match will always return true for an empty query! + if !input_hashes.is_empty() { + Ok(spent_filter.match_any(&blkhash, &mut input_hashes.into_iter())?) + } else { + Ok(false) + } +} + +/// Given fetched UTXOs and a secret map, find which ones belong to us. +pub(crate) fn find_owned_in_utxos( + sp_receiver: &Receiver, + utxos: Vec, + secrets_map: &HashMap<[u8; 34], PublicKey>, +) -> Result, UtxoData, Scalar)>> { + let mut res: Vec<(Option