bitcoin: add electrum backend

This includes changes from darosior's comment in
https://github.com/wizardsardine/liana/pull/1222#issuecomment-2324894986.
This commit is contained in:
jp1ac4 2024-08-06 15:12:27 +01:00 committed by Michael Mallan
parent c4c2424f24
commit c7ee862020
No known key found for this signature in database
GPG Key ID: 5177CDCEDB0EABEB
9 changed files with 1563 additions and 15 deletions

222
Cargo.lock generated
View File

@ -62,12 +62,32 @@ version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9"
[[package]]
name = "bdk_chain"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c601c4dc7e6c3efa538a0afbb43b964cefab9a9b5e8f352fa0ca38145448a5e7"
dependencies = [
"bitcoin",
"miniscript",
]
[[package]]
name = "bdk_coin_select"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c084bf76f0f67546fc814ffa82044144be1bb4618183a15016c162f8b087ad4"
[[package]]
name = "bdk_electrum"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28906275aeb1f71dc32045670f06c8a26fb17cc62151a99f7425d258f4bda589"
dependencies = [
"bdk_chain",
"electrum-client",
]
[[package]]
name = "bech32"
version = "0.10.0-beta"
@ -139,6 +159,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.0.83"
@ -172,7 +198,24 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys",
"windows-sys 0.48.0",
]
[[package]]
name = "electrum-client"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89008f106be6f303695522f2f4c1f28b40c3e8367ed8b3bb227f1f882cb52cc2"
dependencies = [
"bitcoin",
"byteorder",
"libc",
"log",
"rustls",
"serde",
"serde_json",
"webpki-roots",
"winapi",
]
[[package]]
@ -268,6 +311,7 @@ version = "6.0.0"
dependencies = [
"backtrace",
"bdk_coin_select",
"bdk_electrum",
"bip39",
"dirs",
"fern",
@ -438,6 +482,21 @@ dependencies = [
"thiserror",
]
[[package]]
name = "ring"
version = "0.17.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"spin",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.30.0"
@ -458,12 +517,44 @@ version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki",
"sct",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "ryu"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "secp256k1"
version = "0.28.0"
@ -521,6 +612,12 @@ version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970"
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "syn"
version = "2.0.46"
@ -591,6 +688,12 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "vcpkg"
version = "0.2.15"
@ -609,13 +712,50 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
@ -624,13 +764,29 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
@ -639,42 +795,90 @@ version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zerocopy"
version = "0.7.32"

View File

@ -31,6 +31,10 @@ miniscript = { version = "11.0", features = ["serde", "compiler", "base64"] }
# Coin selection algorithms for spend transaction creation.
bdk_coin_select = "0.3"
# For Electrum backend. This is the latest version with the same bitcoin version as
# the miniscript dependency.
bdk_electrum = { version = "0.14" }
# Don't reinvent the wheel
dirs = "5.0"

View File

@ -0,0 +1,411 @@
use std::{collections::HashSet, convert::TryInto};
use bdk_electrum::{
bdk_chain::{
bitcoin,
local_chain::{CheckPoint, LocalChain},
spk_client::{FullScanRequest, FullScanResult, SyncRequest, SyncResult},
BlockId, ChainPosition, ConfirmationHeightAnchor, TxGraph,
},
electrum_client::{self, Config, ElectrumApi},
ElectrumExt,
};
use super::utils::{
block_id_from_tip, height_i32_from_usize, height_usize_from_i32, outpoints_from_tx,
};
use crate::{
bitcoin::{electrum::utils::tip_from_block_id, BlockChainTip, MempoolEntry, MempoolEntryFees},
config,
};
// Default batch size to use when making requests to the Electrum server.
const DEFAULT_BATCH_SIZE: usize = 200;
// If Electrum takes more than 3 minutes to answer one of our queries, fail.
const RPC_SOCKET_TIMEOUT: u8 = 180;
// Number of retries while communicating with the Electrum server.
// A retry happens with exponential back-off (base 2) so this makes us give up after (1+2+4+8+16+32=) 63 seconds.
const RETRY_LIMIT: u8 = 6;
/// An error in the Electrum client.
#[derive(Debug)]
pub enum Error {
Server(electrum_client::Error),
TipChanged(BlockId, BlockId),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Error::Server(e) => write!(f, "Electrum error: '{}'.", e),
Error::TipChanged(expected, actual) => write!(
f,
"Electrum error: Expected tip '{}' but actual tip was {}.",
tip_from_block_id(*expected),
tip_from_block_id(*actual),
),
}
}
}
pub struct Client(electrum_client::Client);
impl Client {
/// Create a new client and perform sanity checks.
pub fn new(electrum_config: &config::ElectrumConfig) -> Result<Self, Error> {
let config = Config::builder()
.retry(RETRY_LIMIT)
.timeout(Some(RPC_SOCKET_TIMEOUT))
.build();
let client =
bdk_electrum::electrum_client::Client::from_config(&electrum_config.addr, config)
.map_err(Error::Server)?;
Ok(Self(client))
}
pub fn chain_tip(&self) -> Result<BlockChainTip, Error> {
self.0
.block_headers_subscribe()
.map_err(Error::Server)
.map(|notif| BlockChainTip {
height: height_i32_from_usize(notif.height),
hash: notif.header.block_hash(),
})
}
fn genesis_block_header(&self) -> Result<bitcoin::block::Header, Error> {
self.0.block_header(0).map_err(Error::Server)
}
pub fn genesis_block_timestamp(&self) -> Result<u32, Error> {
self.genesis_block_header().map(|header| header.time)
}
pub fn genesis_block(&self) -> Result<BlockChainTip, Error> {
self.genesis_block_header().map(|header| BlockChainTip {
hash: header.block_hash(),
height: 0,
})
}
pub fn broadcast_tx(&self, tx: &bitcoin::Transaction) -> Result<bitcoin::Txid, Error> {
self.0.transaction_broadcast(tx).map_err(Error::Server)
}
pub fn tip_time(&self) -> Result<u32, Error> {
let tip_height = self.chain_tip()?.height;
self.0
.block_header(height_usize_from_i32(tip_height))
.map_err(Error::Server)
.map(|bh| bh.time)
}
fn sync_with_confirmation_height_anchor(
&self,
request: SyncRequest,
fetch_prev_txouts: bool,
) -> Result<SyncResult<ConfirmationHeightAnchor>, Error> {
Ok(self
.0
.sync(request, DEFAULT_BATCH_SIZE, fetch_prev_txouts)
.map_err(Error::Server)?
.with_confirmation_height_anchor())
}
/// Perform the given `SyncRequest` with `ConfirmationTimeHeightAnchor`.
pub fn sync_with_confirmation_time_height_anchor(
&self,
request: SyncRequest,
fetch_prev_txouts: bool,
) -> Result<SyncResult, Error> {
self.0
.sync(request, DEFAULT_BATCH_SIZE, fetch_prev_txouts)
.map_err(Error::Server)?
.with_confirmation_time_height_anchor(&self.0)
.map_err(Error::Server)
}
/// Perform the given `FullScanRequest` with `ConfirmationTimeHeightAnchor`.
pub fn full_scan_with_confirmation_time_height_anchor<K: Ord + Clone>(
&self,
request: FullScanRequest<K>,
stop_gap: usize,
fetch_prev_txouts: bool,
) -> Result<FullScanResult<K>, Error> {
self.0
.full_scan(request, stop_gap, DEFAULT_BATCH_SIZE, fetch_prev_txouts)
.map_err(Error::Server)?
.with_confirmation_time_height_anchor(&self.0)
.map_err(Error::Server)
}
/// Get mempool entries.
///
/// If `expected_tip` is specified, the function will return `Error::TipChanged` if the chain tip
/// changes while the entries are being found. Otherwise, the function will restart in case the
/// chain tip changes before completion.
fn mempool_entries(
&self,
txids: HashSet<bitcoin::Txid>,
expected_tip: Option<CheckPoint>,
) -> Result<Vec<MempoolEntry>, Error> {
log::debug!("Getting mempool entries for txids '{:?}'.", txids);
let mut graph = TxGraph::default();
let mut local_chain = LocalChain::from_genesis_hash(self.genesis_block()?.hash).0;
let tip_block = if let Some(ref expected_tip) = expected_tip {
expected_tip.block_id()
} else {
block_id_from_tip(self.chain_tip()?)
};
if tip_block.height > 0 {
let _ = local_chain
.insert_block(tip_block)
.expect("only contains genesis block");
}
// First, get the tx itself and check it's unconfirmed.
let request = SyncRequest::from_chain_tip(local_chain.tip()).chain_txids(txids.clone());
// We'll get prev txouts for this tx when we find its ancestors below.
let sync_result = self.sync_with_confirmation_height_anchor(request, false)?;
let _ = local_chain.apply_update(sync_result.chain_update);
// Store local tip after first sync. This will be our reference tip.
let local_tip = local_chain.tip();
if let Some(ref expected_tip) = expected_tip {
if expected_tip != &local_chain.tip() {
return Err(Error::TipChanged(
expected_tip.block_id(),
local_chain.tip().block_id(),
));
}
}
let mut desc_ops = Vec::new();
let mut txs = Vec::new();
for txid in &txids {
if let Some(ChainPosition::Unconfirmed(_)) = sync_result
.graph_update
.get_chain_position(&local_chain, local_chain.tip().block_id(), *txid)
{
let tx = sync_result
.graph_update
.get_tx(*txid)
.expect("we must have tx in graph after sync");
desc_ops.extend(outpoints_from_tx(&tx));
txs.push(tx);
}
}
let _ = graph.apply_update(sync_result.graph_update);
// Now iterate over increasing depths of descendants.
// As they are descendants, we can assume they are all unconfirmed.
while !desc_ops.is_empty() {
log::debug!("Syncing descendant outpoints: {:?}", desc_ops);
let request = SyncRequest::from_chain_tip(local_chain.tip())
.cache_graph_txs(&graph)
.chain_outpoints(desc_ops.clone());
// Fetch prev txouts to ensure we have all required txs in the graph to calculate fees.
// An unconfirmed descendant may have a confirmed parent that we wouldn't have in our graph.
let sync_result = self.sync_with_confirmation_height_anchor(request, true)?;
let _ = local_chain.apply_update(sync_result.chain_update);
if let Some(ref expected_tip) = expected_tip {
if expected_tip != &local_chain.tip() {
return Err(Error::TipChanged(
expected_tip.block_id(),
local_chain.tip().block_id(),
));
}
}
if local_chain.tip() != local_tip {
log::debug!("Chain tip changed while getting mempool entry. Restarting.");
return self.mempool_entries(txids, expected_tip.clone());
}
let _ = graph.apply_update(sync_result.graph_update);
// Get any txids spending the outpoints we've just synced against.
let desc_txids: HashSet<_> = graph
.filter_chain_txouts(
&local_chain,
local_chain.tip().block_id(),
desc_ops.iter().map(|op| ((), *op)),
)
.filter_map(|(_, txout)| txout.spent_by.map(|(_, spend_txid)| spend_txid))
.collect();
desc_ops = desc_txids
.iter()
.flat_map(|txid| {
let desc_tx = graph
.get_tx(*txid)
.expect("we must have tx in graph after sync");
outpoints_from_tx(&desc_tx)
})
.collect();
}
// For each unconfirmed transaction, starting with `txid`, get its direct ancestors, which may be confirmed or unconfirmed.
// Continue until there are no more unconfirmed ancestors.
// Confirmed transactions will be filtered out from `anc_txids` later on.
let mut anc_txids: HashSet<_> = txs
.iter()
.flat_map(|tx| tx.input.iter().map(|txin| txin.previous_output.txid))
.collect();
while !anc_txids.is_empty() {
log::debug!("Syncing ancestor txids: {:?}", anc_txids);
let request = SyncRequest::from_chain_tip(local_chain.tip())
.cache_graph_txs(&graph)
.chain_txids(anc_txids.clone());
// We expect to have prev txouts for all unconfirmed ancestors in our graph so no need to fetch them here.
// Note we keep iterating through ancestors until we find one that is confirmed and only need to calculate
// fees for unconfirmed transactions.
let sync_result = self.sync_with_confirmation_height_anchor(request, false)?;
let _ = local_chain.apply_update(sync_result.chain_update);
if let Some(expected_tip) = &expected_tip {
if expected_tip != &local_chain.tip() {
return Err(Error::TipChanged(
expected_tip.block_id(),
local_chain.tip().block_id(),
));
}
}
if local_chain.tip() != local_tip {
log::debug!("Chain tip changed while getting mempool entry. Restarting.");
return self.mempool_entries(txids, expected_tip);
}
let _ = graph.apply_update(sync_result.graph_update);
// Add ancestors of any unconfirmed txs.
anc_txids = anc_txids
.iter()
.filter_map(|anc_txid| {
if let Some(ChainPosition::Unconfirmed(_)) = graph.get_chain_position(
&local_chain,
local_chain.tip().block_id(),
*anc_txid,
) {
let anc_tx = graph.get_tx(*anc_txid).expect("we must have it");
Some(
anc_tx
.input
.clone()
.iter()
.map(|txin| txin.previous_output.txid)
.collect::<HashSet<_>>(),
)
} else {
None
}
})
.flatten()
.collect();
}
let mut entries = Vec::new();
for tx in txs {
// Now iterate over ancestors and descendants in the graph.
let base_fee = graph
.calculate_fee(&tx)
.expect("all required txs are in graph");
let base_size = tx.vsize();
// Ancestor & descendant fees include those of `txid`.
let mut desc_fees = base_fee;
let mut anc_fees = base_fee;
// Ancestor size includes that of `txid`.
let mut anc_size = base_size;
for desc_txid in graph.walk_descendants(tx.txid(), |_, desc_txid| Some(desc_txid)) {
log::debug!("Getting fee for desc txid '{}'.", desc_txid);
let desc_tx = graph
.get_tx(desc_txid)
.expect("all descendant txs are in graph");
let fee = graph
.calculate_fee(&desc_tx)
.expect("all required txs are in graph");
desc_fees += fee;
}
for anc_tx in graph.walk_ancestors(tx, |_, anc_tx| Some(anc_tx)) {
log::debug!("Getting fee and size for anc txid '{}'.", anc_tx.txid());
if let Some(ChainPosition::Unconfirmed(_)) = graph.get_chain_position(
&local_chain,
local_chain.tip().block_id(),
anc_tx.txid(),
) {
let fee = graph
.calculate_fee(&anc_tx)
.expect("all required txs are in graph");
anc_fees += fee;
anc_size += anc_tx.vsize();
} else {
log::debug!("Ancestor txid '{}' is not unconfirmed.", anc_tx.txid());
continue;
}
}
let fees = MempoolEntryFees {
base: bitcoin::Amount::from_sat(base_fee),
ancestor: bitcoin::Amount::from_sat(anc_fees),
descendant: bitcoin::Amount::from_sat(desc_fees),
};
let entry = MempoolEntry {
vsize: base_size.try_into().expect("tx size must fit into u64"),
fees,
ancestor_vsize: anc_size.try_into().expect("tx size must fit into u64"),
};
entries.push(entry)
}
// It's possible that the chain tip has now changed, but it hadn't done as of the last sync,
// so go ahead and return the results.
Ok(entries)
}
/// Get mempool entry for a single `txid`.
///
/// Convenience method to call `mempool_entries` for a single `txid`,
/// returning `Option` instead of `Vec`.
pub fn mempool_entry(&self, txid: &bitcoin::Txid) -> Result<Option<MempoolEntry>, Error> {
// We just require the chain tip to stay the same while running `mempool_entries` so
// don't need to pass in an expected tip.
self.mempool_entries(HashSet::from([*txid]), None)
.map(|entries| entries.first().cloned())
}
/// Get mempool spenders of the given outpoints.
///
/// Will restart if chain tip changes before completion.
pub fn mempool_spenders(
&self,
outpoints: &[bitcoin::OutPoint],
) -> Result<Vec<MempoolEntry>, Error> {
log::debug!("Getting mempool spenders for outpoints: {:?}.", outpoints);
let mut local_chain = LocalChain::from_genesis_hash(self.genesis_block()?.hash).0;
let chain_tip = self.chain_tip()?;
if chain_tip.height > 0 {
let _ = local_chain
.insert_block(block_id_from_tip(chain_tip))
.expect("only contains genesis block");
}
let request =
SyncRequest::from_chain_tip(local_chain.tip()).chain_outpoints(outpoints.to_vec());
// We don't need to fetch prev txouts as we just want the outspends.
let sync_result = self.sync_with_confirmation_height_anchor(request, false)?;
let _ = local_chain.apply_update(sync_result.chain_update);
// Store tip at which first sync was completed. This will be our reference tip.
let local_tip = local_chain.tip();
let graph = sync_result.graph_update;
let txids: HashSet<_> = outpoints
.iter()
.flat_map(|op| graph.outspends(*op))
.copied()
.collect();
let entries = match self.mempool_entries(txids, Some(local_tip)) {
Ok(entries) => entries,
Err(Error::TipChanged(expected, actual)) => {
log::debug!(
"Chain tip changed from {:?} to {:?} while \
getting mempool spenders. Restarting.",
expected,
actual
);
return self.mempool_spenders(outpoints);
}
Err(e) => {
return Err(e);
}
};
Ok(entries)
}
}

250
src/bitcoin/electrum/mod.rs Normal file
View File

@ -0,0 +1,250 @@
use std::collections::HashMap;
use bdk_electrum::bdk_chain::{
bitcoin::{self, bip32::ChildNumber, BlockHash, OutPoint},
local_chain::LocalChain,
spk_client::{FullScanRequest, SyncRequest},
ChainPosition,
};
pub mod client;
mod utils;
pub mod wallet;
use crate::bitcoin::{Block, BlockChainTip, Coin};
/// An error in the Electrum interface.
#[derive(Debug)]
pub enum ElectrumError {
Client(client::Error),
GenesisHashMismatch(
BlockHash, /*expected hash*/
BlockHash, /*server hash*/
BlockHash, /*wallet hash*/
),
}
impl std::fmt::Display for ElectrumError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
ElectrumError::Client(e) => write!(f, "Electrum client error: '{}'.", e),
ElectrumError::GenesisHashMismatch(expected, server, wallet) => {
write!(
f,
"Genesis hash mismatch. The genesis hash is expected to be '{}'. \
The server has hash '{}' and the wallet has hash '{}'.",
expected, server, wallet,
)
}
}
}
}
/// Interface for Electrum backend.
pub struct Electrum {
client: client::Client,
bdk_wallet: wallet::BdkWallet,
/// Used for setting the `last_seen` of unconfirmed transactions in a strictly
/// increasing manner.
sync_count: u64,
/// Set to `true` to force a full scan from the genesis block regardless of
/// the wallet's local chain height.
full_scan: bool,
}
impl Electrum {
pub fn new(
client: client::Client,
bdk_wallet: wallet::BdkWallet,
) -> Result<Self, ElectrumError> {
Ok(Self {
client,
bdk_wallet,
sync_count: 0,
full_scan: false, // by default, only perform full scan if wallet's local chain has height 0
})
}
pub fn sanity_checks(&self, expected_hash: &bitcoin::BlockHash) -> Result<(), ElectrumError> {
let server_hash = self
.client
.genesis_block()
.map_err(ElectrumError::Client)?
.hash;
let wallet_hash = self.bdk_wallet.local_chain().genesis_hash();
if server_hash != *expected_hash || wallet_hash != *expected_hash {
return Err(ElectrumError::GenesisHashMismatch(
*expected_hash,
server_hash,
wallet_hash,
));
}
Ok(())
}
pub fn client(&self) -> &client::Client {
&self.client
}
fn local_chain(&self) -> &LocalChain {
self.bdk_wallet.local_chain()
}
/// Get all coins stored in the wallet, taking into consideration only those unconfirmed
/// transactions that were seen in the last wallet sync.
pub fn wallet_coins(&self, outpoints: Option<&[OutPoint]>) -> HashMap<OutPoint, Coin> {
self.bdk_wallet.coins(outpoints, Some(self.sync_count))
}
/// Get the tip of the wallet's local chain.
pub fn wallet_tip(&self) -> BlockChainTip {
utils::tip_from_block_id(self.local_chain().tip().block_id())
}
/// Whether `tip` exists in the wallet's `local_chain`.
///
/// Returns `None` if no block at that height exists in `local_chain`.
pub fn is_in_wallet_chain(&self, tip: BlockChainTip) -> Option<bool> {
self.bdk_wallet.is_in_chain(tip)
}
/// Whether we'll perform a full scan at the next poll.
pub fn is_rescanning(&self) -> bool {
self.full_scan || self.local_chain().tip().height() == 0
}
/// Make the poller perform a full scan on the next iteration.
pub fn trigger_rescan(&mut self) {
self.full_scan = true;
}
/// Sync the wallet with the Electrum server. If there was any reorg since the last poll, this
/// returns the first common ancestor between the previous and the new chain.
pub fn sync_wallet(
&mut self,
receive_index: ChildNumber,
change_index: ChildNumber,
) -> Result<Option<BlockChainTip>, ElectrumError> {
self.bdk_wallet.reveal_spks(receive_index, change_index);
let local_chain_tip = self.local_chain().tip();
log::debug!(
"local chain tip height before sync with electrum: {}",
local_chain_tip.block_id().height
);
// We'll only need to calculate fees of mempool transactions and this will be done separately from our graph
// so we don't need to fetch prev txouts. In any case, we'll already have these for our own transactions.
const FETCH_PREV_TXOUTS: bool = false;
const STOP_GAP: usize = 50;
let (chain_update, mut graph_update, keychain_update) = if !self.is_rescanning() {
log::info!("Performing sync.");
let mut request = SyncRequest::from_chain_tip(local_chain_tip.clone())
.cache_graph_txs(self.bdk_wallet.graph());
let all_spks: Vec<_> = self
.bdk_wallet
.index()
.inner() // we include lookahead SPKs
.all_spks()
.iter()
.map(|(_, script)| script.clone())
.collect();
request = request.chain_spks(all_spks);
log::debug!("num SPKs for sync: {}", request.spks.len());
let sync_result = self
.client
.sync_with_confirmation_time_height_anchor(request, FETCH_PREV_TXOUTS)
.map_err(ElectrumError::Client)?;
log::info!("Sync complete.");
(sync_result.chain_update, sync_result.graph_update, None)
} else {
log::info!("Performing full scan.");
// Either local_chain has height 0 or we want to trigger a full scan.
// In both cases, the scan should be from the genesis block.
let genesis_block = local_chain_tip.get(0).expect("must contain genesis block");
let mut request = FullScanRequest::from_chain_tip(genesis_block)
.cache_graph_txs(self.bdk_wallet.graph());
for (k, spks) in self.bdk_wallet.index().all_unbounded_spk_iters() {
request = request.set_spks_for_keychain(k, spks);
}
let scan_result = self
.client
.full_scan_with_confirmation_time_height_anchor(
request,
STOP_GAP,
FETCH_PREV_TXOUTS,
)
.map_err(ElectrumError::Client)?;
// A full scan only makes sense to do once, in most cases. Don't do it again unless
// explicitly asked to by a user.
self.full_scan = false;
log::info!("Full scan complete.");
(
scan_result.chain_update,
scan_result.graph_update,
Some(scan_result.last_active_indices),
)
};
log::debug!(
"chain update height after sync with electrum: {}",
chain_update.height()
);
// Increment the sync count and apply changes.
self.sync_count = self.sync_count.checked_add(1).expect("must fit");
if let Some(keychain_update) = keychain_update {
self.bdk_wallet.apply_keychain_update(keychain_update);
}
let changeset = self.bdk_wallet.apply_connected_chain_update(chain_update);
let mut changes_iter = changeset.into_iter();
let reorg_common_ancestor = loop {
match changes_iter.next() {
Some((height, Some(_))) => {
// `BlockHash` being `Some(_)` means a checkpoint at this height was added to the chain.
// Since we iterate in ascending height order, we'll see the lowest block height first.
// If the lowest height it adds is higher than our height before syncing, we're good.
// Else if it's adding a block at height before syncing or lower, it's a reorg.
break if height > local_chain_tip.height() {
None
} else {
log::info!("Block chain reorganization detected.");
Some(self.bdk_wallet.find_block_at_or_before_height(height))
};
}
Some((_, None)) => continue,
None => break None,
}
};
// Unconfirmed transactions have their last seen as 0, so we override to the `sync_count`
// so that conflicts can be properly handled. We use `sync_count` instead of current time
// in seconds to ensure strictly increasing values between poller iterations.
for tx in &graph_update.initial_changeset().txs {
let txid = tx.txid();
if let Some(ChainPosition::Unconfirmed(_)) = graph_update.get_chain_position(
self.local_chain(),
self.local_chain().tip().block_id(),
txid,
) {
log::debug!(
"changing last seen for txid '{}' to {}",
txid,
self.sync_count
);
let _ = graph_update.insert_seen_at(txid, self.sync_count);
}
}
self.bdk_wallet.apply_graph_update(graph_update);
Ok(reorg_common_ancestor)
}
pub fn wallet_transaction(
&self,
txid: &bitcoin::Txid,
) -> Option<(bitcoin::Transaction, Option<Block>)> {
self.bdk_wallet.get_transaction(txid)
}
}

View File

@ -0,0 +1,55 @@
use std::convert::TryInto;
use bdk_electrum::bdk_chain::{bitcoin, BlockId, ConfirmationTimeHeightAnchor};
use crate::bitcoin::{BlockChainTip, BlockInfo};
pub fn height_u32_from_i32(height: i32) -> u32 {
height.try_into().expect("height must fit into u32")
}
pub fn height_i32_from_u32(height: u32) -> i32 {
height.try_into().expect("height must fit into i32")
}
pub fn height_i32_from_usize(height: usize) -> i32 {
height.try_into().expect("height must fit into i32")
}
pub fn height_usize_from_i32(height: i32) -> usize {
height.try_into().expect("height must fit into usize")
}
pub fn block_id_from_tip(tip: BlockChainTip) -> BlockId {
BlockId {
height: height_u32_from_i32(tip.height),
hash: tip.hash,
}
}
pub fn tip_from_block_id(id: BlockId) -> BlockChainTip {
BlockChainTip {
height: height_i32_from_u32(id.height),
hash: id.hash,
}
}
pub fn block_info_from_anchor(anchor: ConfirmationTimeHeightAnchor) -> BlockInfo {
BlockInfo {
height: height_i32_from_u32(anchor.confirmation_height),
time: anchor
.confirmation_time
.try_into()
.expect("u32 by consensus"),
}
}
/// Get the transaction's outpoints.
pub fn outpoints_from_tx(tx: &bitcoin::Transaction) -> Vec<bitcoin::OutPoint> {
let txid = tx.txid();
(0..tx.output.len())
.map(|i| {
bitcoin::OutPoint::new(txid, i.try_into().expect("num tx outputs must fit in u32"))
})
.collect::<Vec<_>>()
}

View File

@ -0,0 +1,328 @@
use std::{
collections::{BTreeMap, HashMap},
convert::TryInto,
sync::Arc,
};
use bdk_electrum::bdk_chain::{
bitcoin::{self, bip32, BlockHash, OutPoint, ScriptBuf, TxOut},
keychain::KeychainTxOutIndex,
local_chain::{ChangeSet as ChainChangeSet, CheckPoint, LocalChain},
miniscript::{Descriptor, DescriptorPublicKey},
tx_graph::{self, TxGraph},
ChainOracle, ChainPosition, ConfirmationTimeHeightAnchor, IndexedTxGraph,
};
use miniscript::bitcoin::bip32::ChildNumber;
use super::utils::{
block_id_from_tip, block_info_from_anchor, height_i32_from_u32, height_u32_from_i32,
};
use crate::{
bitcoin::{Block, BlockChainTip, Coin, COINBASE_MATURITY},
descriptors::LianaDescriptor,
};
// TODO: Move and reuse `liana::database::sqlite::utils::LOOK_AHEAD_LIMIT`?
const LOOK_AHEAD_LIMIT: u32 = 200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum KeychainType {
Receive,
Change,
}
pub struct BdkWallet {
graph: IndexedTxGraph<ConfirmationTimeHeightAnchor, KeychainTxOutIndex<KeychainType>>,
local_chain: LocalChain,
// Store descriptors for use when getting SPKs.
receive_desc: Descriptor<DescriptorPublicKey>,
change_desc: Descriptor<DescriptorPublicKey>,
}
impl BdkWallet {
/// Create a new BDK wallet and initialize with the given data that was
/// valid as of `tip`.
///
/// If there is no `tip`, then any provided data will be ignored.
///
/// `receive_index` and `change_index` are the last used derivation
/// indices for the receive and change descriptors, respectively.
pub fn new(
main_descriptor: &LianaDescriptor,
genesis_hash: BlockHash,
tip: Option<BlockChainTip>,
coins: &[Coin],
txs: &[bitcoin::Transaction],
receive_index: ChildNumber,
change_index: ChildNumber,
) -> Self {
let local_chain = LocalChain::from_genesis_hash(genesis_hash).0;
let receive_desc = main_descriptor
.receive_descriptor()
.as_descriptor_public_key();
let change_desc = main_descriptor
.change_descriptor()
.as_descriptor_public_key();
let mut bdk_wallet = BdkWallet {
graph: {
let mut indexer = KeychainTxOutIndex::<KeychainType>::new(LOOK_AHEAD_LIMIT);
let _ = indexer.insert_descriptor(KeychainType::Receive, receive_desc.clone());
let _ = indexer.insert_descriptor(KeychainType::Change, change_desc.clone());
IndexedTxGraph::new(indexer)
},
local_chain,
receive_desc: receive_desc.clone(),
change_desc: change_desc.clone(),
};
if let Some(tip) = tip {
// This will be our anchor for any confirmed transactions.
let anchor_block = block_id_from_tip(tip);
if tip.height > 0 {
log::debug!("inserting block into local chain: {:?}", anchor_block);
let _ = bdk_wallet
.local_chain
.insert_block(anchor_block)
.expect("local chain only contains genesis block");
}
// Update the last used derivation index for both change and receive addresses.
log::debug!(
"revealing SPKs up to receive index {receive_index} and change index {change_index}"
);
bdk_wallet.reveal_spks(receive_index, change_index);
// Update the existing coins and transactions information using a TxGraph changeset.
log::debug!("Number of coins to load: {}.", coins.len());
log::debug!("Number of txs to load: {}.", txs.len());
let mut graph_cs = tx_graph::ChangeSet::default();
for tx in txs {
graph_cs.txs.insert(Arc::new(tx.clone()));
}
for coin in coins {
// First of all insert the txout itself.
let script_pubkey = bdk_wallet.get_spk(coin.derivation_index, coin.is_change);
let txout = TxOut {
script_pubkey,
value: coin.amount,
};
graph_cs.txouts.insert(coin.outpoint, txout);
// If the coin's deposit transaction is confirmed, tell BDK by inserting an anchor.
// Otherwise, we could insert a last seen timestamp but we don't have such data stored in
// the table.
if let Some(block) = coin.block_info {
graph_cs.anchors.insert((
ConfirmationTimeHeightAnchor {
confirmation_height: height_u32_from_i32(block.height),
confirmation_time: block.time.into(),
anchor_block,
},
coin.outpoint.txid,
));
}
// If the coin's spending transaction is confirmed, do the same.
if let Some(block) = coin.spend_block {
let spend_txid = coin.spend_txid.expect("Must be present if confirmed.");
graph_cs.anchors.insert((
ConfirmationTimeHeightAnchor {
confirmation_height: height_u32_from_i32(block.height),
confirmation_time: block.time.into(),
anchor_block,
},
spend_txid,
));
}
}
let mut graph = TxGraph::default();
graph.apply_changeset(graph_cs);
let _ = bdk_wallet.graph.apply_update(graph);
}
bdk_wallet
}
/// Get a reference to the local chain.
pub fn local_chain(&self) -> &LocalChain {
&self.local_chain
}
/// Whether `tip` exists in `local_chain`.
///
/// Returns `None` if no block at that height exists in `local_chain`.
pub fn is_in_chain(&self, tip: BlockChainTip) -> Option<bool> {
self.local_chain
.is_block_in_chain(block_id_from_tip(tip), self.local_chain().tip().block_id())
.expect("function is infallible")
}
/// Get a reference to the graph.
pub fn graph(&self) -> &TxGraph<ConfirmationTimeHeightAnchor> {
self.graph.graph()
}
/// Get a reference to the transaction index.
pub fn index(&self) -> &KeychainTxOutIndex<KeychainType> {
&self.graph.index
}
/// Reveal SPKs based on derivation indices set in DB.
pub fn reveal_spks(&mut self, receive_index: ChildNumber, change_index: ChildNumber) {
let mut keychain_update = BTreeMap::new();
keychain_update.insert(KeychainType::Receive, receive_index.into());
keychain_update.insert(KeychainType::Change, change_index.into());
self.apply_keychain_update(keychain_update)
}
fn get_spk(&self, der_index: bip32::ChildNumber, is_change: bool) -> ScriptBuf {
// Try to get it from the BDK wallet cache first, failing that derive it from the appropriate
// descriptor.
let chain_kind = if is_change {
KeychainType::Change
} else {
KeychainType::Receive
};
if let Some(spk) = self.graph.index.spk_at_index(chain_kind, der_index.into()) {
spk.to_owned()
} else {
let desc = if is_change {
&self.change_desc
} else {
&self.receive_desc
};
desc.at_derivation_index(der_index.into())
.expect("Not multipath and index isn't hardened.")
.script_pubkey()
}
}
/// Get the coins currently stored by the `BdkWallet` optionally filtered by `outpoints`.
/// If `outpoints` is `None`, no filter will be applied.
/// If `outpoints` is an empty slice, no coins will be returned.
/// If `last_seen` is set, only those unconfirmed transactions with a matching last seen
/// will be considered.
pub fn coins(
&self,
outpoints: Option<&[bitcoin::OutPoint]>,
last_seen: Option<u64>,
) -> HashMap<OutPoint, Coin> {
// Get an iterator over all the wallet txos (not only the currently unspent ones) by using
// lower level methods.
let tx_graph = self.graph.graph();
let txo_index = &self.graph.index;
let tip_id = self.local_chain.tip().block_id();
let wallet_txos =
tx_graph.filter_chain_txouts(&self.local_chain, tip_id, txo_index.outpoints());
let mut wallet_coins = HashMap::new();
// Go through all the wallet txos and create a coin for each.
for ((k, i), full_txo) in wallet_txos {
let outpoint = full_txo.outpoint;
if outpoints.map(|ops| !ops.contains(&outpoint)) == Some(true) {
continue;
}
let amount = full_txo.txout.value;
let derivation_index = i.into();
let is_change = matches!(k, KeychainType::Change);
let block_info = match full_txo.chain_position {
ChainPosition::Unconfirmed(ls) => {
if let Some(last_seen) = last_seen.filter(|last_seen| *last_seen != ls) {
log::debug!("Ignoring coin at {}, which was last seen at {} instead of {} as required.", outpoint, ls, last_seen);
continue;
}
None
}
ChainPosition::Confirmed(anchor) => Some(block_info_from_anchor(anchor)),
};
// Immature if from a coinbase transaction with less than a hundred confs.
let is_immature = full_txo.is_on_coinbase
&& block_info
.and_then(|blk| {
let tip_height: i32 = height_i32_from_u32(tip_id.height);
tip_height
.checked_sub(blk.height)
.map(|confs| confs < COINBASE_MATURITY)
})
.unwrap_or(true);
// Get spend status of this coin.
let (mut spend_txid, mut spend_block) = (None, None);
if let Some((spend_pos, txid)) = full_txo.spent_by {
spend_txid = Some(txid);
match spend_pos {
ChainPosition::Unconfirmed(ls) => {
if let Some(last_seen) = last_seen.filter(|last_seen| *last_seen != ls) {
log::debug!(
"Ignoring spend txid {} for coin at {}, \
which was last seen at {} instead of {} as required.",
txid,
outpoint,
ls,
last_seen
);
spend_txid = None;
}
}
ChainPosition::Confirmed(anchor) => {
spend_block = Some(block_info_from_anchor(anchor));
}
};
}
let coin = crate::bitcoin::Coin {
outpoint,
amount,
derivation_index,
is_change,
is_immature,
block_info,
spend_txid,
spend_block,
};
wallet_coins.insert(coin.outpoint, coin);
}
wallet_coins
}
pub fn get_transaction(
&self,
txid: &bitcoin::Txid,
) -> Option<(bitcoin::Transaction, Option<Block>)> {
self.graph.graph().get_tx_node(*txid).map(|tx_node| {
let block = tx_node.anchors.iter().next().map(|info| Block {
hash: info.anchor_block.hash, // not necessarily the confirmation block hash
height: height_i32_from_u32(info.confirmation_height),
time: info.confirmation_time.try_into().expect("u32 by consensus"),
});
let tx = tx_node.tx.as_ref().clone();
(tx, block)
})
}
/// Find the first block in the local chain whose height is less than or equal to this.
pub fn find_block_at_or_before_height(&self, height: u32) -> BlockChainTip {
for cp in self.local_chain.iter_checkpoints() {
if cp.height() <= height {
return BlockChainTip {
height: height_i32_from_u32(cp.height()),
hash: cp.hash(),
};
}
}
unreachable!("There must be at least the genesis block.")
}
/// Apply an update to the local chain.
/// Panics if update does not connect to the local chain.
pub fn apply_connected_chain_update(&mut self, chain_update: CheckPoint) -> ChainChangeSet {
self.local_chain
.apply_update(chain_update)
.expect("update must connect to local chain")
}
/// Apply a graph update.
pub fn apply_graph_update(&mut self, graph_update: TxGraph<ConfirmationTimeHeightAnchor>) {
let _ = self.graph.apply_update(graph_update);
}
/// Apply a keychain update.
pub fn apply_keychain_update(&mut self, keychain_update: BTreeMap<KeychainType, u32>) {
let _ = self.graph.index.reveal_to_target_multi(&keychain_update);
}
}

View File

@ -3,6 +3,7 @@
//! Broadcast transactions, poll for new unspent coins, gather fee estimates.
pub mod d;
pub mod electrum;
pub mod poller;
use crate::{
@ -405,6 +406,193 @@ impl BitcoinInterface for d::BitcoinD {
}
}
impl BitcoinInterface for electrum::Electrum {
fn sync_wallet(
&mut self,
receive_index: ChildNumber,
change_index: ChildNumber,
) -> Result<Option<BlockChainTip>, String> {
self.sync_wallet(receive_index, change_index)
.map_err(|e| e.to_string())
}
fn received_coins(
&self,
tip: &BlockChainTip,
_descs: &[descriptors::SinglePathLianaDesc],
) -> Vec<UTxO> {
// Get those wallet coins that are either unconfirmed or have a confirmation height
// after tip. The poller will then discard any that had already been received.
self.wallet_coins(None)
.values()
.filter_map(|c| {
let height = c.block_info.map(|info| info.height);
if height.filter(|h| *h <= tip.height).is_some() {
None
} else {
Some(UTxO {
outpoint: c.outpoint,
block_height: height,
amount: c.amount,
address: UTxOAddress::DerivIndex(c.derivation_index, c.is_change),
is_immature: c.is_immature,
})
}
})
.collect()
}
fn confirmed_coins(
&self,
outpoints: &[bitcoin::OutPoint],
) -> (Vec<(bitcoin::OutPoint, i32, u32)>, Vec<bitcoin::OutPoint>) {
let wallet_coins = &self.wallet_coins(Some(outpoints));
let mut confirmed = Vec::new();
let mut expired = Vec::new();
for op in outpoints {
if let Some(w_c) = wallet_coins.get(op) {
if let Some(block) = w_c.block_info {
if w_c.is_immature {
log::debug!(
"Coin at '{}' comes from an immature coinbase transaction at \
block height {}. Not marking it as confirmed for now.",
op,
block.height
);
continue;
}
confirmed.push((w_c.outpoint, block.height, block.time));
}
} else {
expired.push(*op);
}
}
(confirmed, expired)
}
fn spending_coins(
&self,
outpoints: &[bitcoin::OutPoint],
) -> Vec<(bitcoin::OutPoint, bitcoin::Txid)> {
let wallet_coins = &self.wallet_coins(Some(outpoints));
outpoints
.iter()
.filter_map(|op| {
if let Some(w_c) = wallet_coins.get(op) {
w_c.spend_txid.map(|txid| (w_c.outpoint, txid))
} else {
None
}
})
.collect()
}
fn spent_coins(
&self,
outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)],
) -> (Vec<SpentCoin>, Vec<bitcoin::OutPoint>) {
let ops: Vec<_> = outpoints.iter().map(|(op, _)| op).copied().collect();
let wallet_coins = &self.wallet_coins(Some(&ops));
let mut spent = Vec::new();
let mut expired_spending = Vec::new();
for (op, spend_txid) in outpoints {
if let Some(w_c) = wallet_coins.get(op) {
if w_c.spend_txid != Some(*spend_txid) {
expired_spending.push(*op);
}
if let Some(block) = w_c.spend_block {
spent.push((*op, *spend_txid, block.height, block.time));
}
}
}
(spent, expired_spending)
}
fn genesis_block_timestamp(&self) -> u32 {
self.client()
.genesis_block_timestamp()
.expect("Genesis block timestamp must always be there")
}
fn genesis_block(&self) -> BlockChainTip {
self.client()
.genesis_block()
.expect("Genesis block must always be there")
}
fn chain_tip(&self) -> BlockChainTip {
// We want the wallet's local chain tip after syncing.
self.wallet_tip()
}
fn is_in_chain(&self, tip: &BlockChainTip) -> bool {
// Return `false` if no block at same height as `tip`
// is in wallet's local chain.
self.is_in_wallet_chain(*tip).unwrap_or_default()
}
/// FIXME: make the Bitcoin backend interface higher level. See the comment in the poller next
/// to the `sync_wallet()` call.
fn common_ancestor(&self, _tip: &BlockChainTip) -> Option<BlockChainTip> {
unreachable!("The common ancestor is returned in `sync_wallet()`. If no reorg was detected then, this method will never be called on an Electrum backend.")
}
fn broadcast_tx(&self, tx: &bitcoin::Transaction) -> Result<(), String> {
match self.client().broadcast_tx(tx) {
Ok(_txid) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
fn wallet_transaction(
&self,
txid: &bitcoin::Txid,
) -> Option<(bitcoin::Transaction, Option<Block>)> {
self.wallet_transaction(txid)
}
fn mempool_entry(&self, txid: &bitcoin::Txid) -> Option<MempoolEntry> {
self.client().mempool_entry(txid).ok()?
}
fn mempool_spenders(&self, outpoints: &[bitcoin::OutPoint]) -> Vec<MempoolEntry> {
self.client()
.mempool_spenders(outpoints)
.unwrap_or_default()
}
fn sync_progress(&self) -> SyncProgress {
// Always return 100% for now since the API is bitcoind-specific to mean "blocks/headers".
// But in the future it would be nice to inform the user about the progress of the sync
// if it takes a few dozen seconds.
let blocks = self.chain_tip().height as u64;
SyncProgress::new(1.0, blocks, blocks)
}
fn start_rescan(
&mut self,
_desc: &descriptors::LianaDescriptor,
_timestamp: u32,
) -> Result<(), String> {
self.trigger_rescan();
Ok(())
}
fn rescan_progress(&self) -> Option<f64> {
// Until we sync we're at 0%. After the sync, we're at 100%.
self.is_rescanning().then_some(0.0)
}
fn block_before_date(&self, _timestamp: u32) -> Option<BlockChainTip> {
Some(self.genesis_block())
}
fn tip_time(&self) -> Option<u32> {
self.client().tip_time().ok()
}
}
// FIXME: do we need to repeat the entire trait implemenation? Isn't there a nicer way?
impl BitcoinInterface for sync::Arc<sync::Mutex<dyn BitcoinInterface + 'static>> {
fn genesis_block_timestamp(&self) -> u32 {
@ -528,3 +716,21 @@ pub enum UTxOAddress {
/// Derivation index and whether it is from the change descriptor.
DerivIndex(ChildNumber, bool),
}
#[derive(Debug, Clone, Copy)]
pub struct BlockInfo {
pub height: i32,
pub time: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct Coin {
pub outpoint: bitcoin::OutPoint,
pub amount: bitcoin::Amount,
pub derivation_index: ChildNumber,
pub is_change: bool,
pub is_immature: bool,
pub block_info: Option<BlockInfo>,
pub spend_txid: Option<bitcoin::Txid>,
pub spend_block: Option<BlockInfo>,
}

View File

@ -91,6 +91,9 @@ pub enum BitcoinBackend {
/// Settings specific to bitcoind as the Bitcoin interface.
#[serde(rename = "bitcoind_config")]
Bitcoind(BitcoindConfig),
/// Settings specific to Electrum as the Bitcoin interface.
#[serde(rename = "electrum_config")]
Electrum(ElectrumConfig),
}
/// RPC authentication options.
@ -123,6 +126,15 @@ pub struct BitcoindConfig {
pub addr: SocketAddr,
}
/// Everything we need to know for talking to Electrum serenely.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ElectrumConfig {
/// The URL the Electrum's RPC is listening on.
/// Include "ssl://" for SSL. otherwise TCP will be assumed.
/// Can optionally prefix with "tcp://".
pub addr: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BitcoinConfig {
/// The network we are operating on, one of "bitcoin", "testnet", "regtest", "signet"

View File

@ -14,9 +14,13 @@ pub mod spend;
mod testutils;
pub use bip39;
use bitcoin::electrum;
pub use miniscript;
pub use crate::bitcoin::d::{BitcoinD, BitcoindError, WalletError};
pub use crate::bitcoin::{
d::{BitcoinD, BitcoindError, WalletError},
electrum::{Electrum, ElectrumError},
};
#[cfg(feature = "daemon")]
use crate::jsonrpc::server::{rpcserver_loop, rpcserver_setup};
use crate::{
@ -34,7 +38,7 @@ use std::{
thread,
};
use miniscript::bitcoin::secp256k1;
use miniscript::bitcoin::{constants::ChainHash, hashes::Hash, secp256k1, BlockHash};
#[cfg(not(test))]
use std::panic;
@ -92,10 +96,12 @@ pub enum StartupError {
DefaultDataDirNotFound,
DatadirCreation(path::PathBuf, io::Error),
MissingBitcoindConfig,
MissingElectrumConfig,
MissingBitcoinBackendConfig,
DbMigrateBitcoinTxs(&'static str),
Database(SqliteDbError),
Bitcoind(BitcoindError),
Electrum(ElectrumError),
#[cfg(unix)]
Daemonization(&'static str),
#[cfg(windows)]
@ -118,6 +124,10 @@ impl fmt::Display for StartupError {
f,
"Our Bitcoin interface is bitcoind but we have no 'bitcoind_config' entry in the configuration."
),
Self::MissingElectrumConfig => write!(
f,
"Our Bitcoin interface is Electrum but we have no 'electrum_config' entry in the configuration."
),
Self::MissingBitcoinBackendConfig => write!(
f,
"No Bitcoin backend entry in the configuration."
@ -128,6 +138,7 @@ impl fmt::Display for StartupError {
),
Self::Database(e) => write!(f, "Error initializing database: '{}'.", e),
Self::Bitcoind(e) => write!(f, "Error setting up bitcoind interface: '{}'.", e),
Self::Electrum(e) => write!(f, "Error setting up Electrum interface: '{}'.", e),
#[cfg(unix)]
Self::Daemonization(e) => write!(f, "Error when daemonizing: '{}'.", e),
#[cfg(windows)]
@ -265,10 +276,10 @@ fn setup_bitcoind(
#[cfg(target_os = "windows")]
let wo_path_str = wo_path_str.replace("\\\\?\\", "").replace("\\\\?", "");
let config::BitcoinBackend::Bitcoind(bitcoind_config) = config
.bitcoin_backend
.as_ref()
.ok_or(StartupError::MissingBitcoindConfig)?;
let bitcoind_config = match config.bitcoin_backend.as_ref() {
Some(config::BitcoinBackend::Bitcoind(bitcoind_config)) => bitcoind_config,
_ => Err(StartupError::MissingBitcoindConfig)?,
};
let bitcoind = BitcoinD::new(bitcoind_config, wo_path_str)?;
bitcoind.node_sanity_checks(
config.bitcoin_config.network,
@ -292,6 +303,70 @@ fn setup_bitcoind(
Ok(bitcoind)
}
// Create an Electrum interface from a client and BDK-based wallet, and do some sanity checks.
// If all went well, returns the interface to Electrum.
fn setup_electrum(
config: &Config,
db: sync::Arc<sync::Mutex<dyn DatabaseInterface>>,
) -> Result<Electrum, StartupError> {
let electrum_config = match config.bitcoin_backend.as_ref() {
Some(config::BitcoinBackend::Electrum(electrum_config)) => electrum_config,
_ => Err(StartupError::MissingElectrumConfig)?,
};
// First create the client to communicate with the Electrum server.
let client = electrum::client::Client::new(electrum_config)
.map_err(|e| StartupError::Electrum(ElectrumError::Client(e)))?;
// Then create the BDK-based wallet and populate it with DB data.
let mut db_conn = db.connection();
let tip = db_conn.chain_tip();
let coins: Vec<_> = db_conn
.coins(&[], &[])
.into_values()
.map(|c| crate::bitcoin::Coin {
outpoint: c.outpoint,
amount: c.amount,
derivation_index: c.derivation_index,
is_change: c.is_change,
is_immature: c.is_immature,
block_info: c.block_info.map(|info| crate::bitcoin::BlockInfo {
height: info.height,
time: info.time,
}),
spend_txid: c.spend_txid,
spend_block: c.spend_block.map(|info| crate::bitcoin::BlockInfo {
height: info.height,
time: info.time,
}),
})
.collect();
let txids = db_conn.list_saved_txids();
// This will only return those txs referenced by our coins, which may not be all of `txids`.
let txs: Vec<_> = db_conn
.list_wallet_transactions(&txids)
.into_iter()
.map(|(tx, _, _)| tx)
.collect();
let (receive_index, change_index) = (db_conn.receive_index(), db_conn.change_index());
let genesis_hash = {
let chain_hash = ChainHash::using_genesis_block(config.bitcoin_config.network);
BlockHash::from_byte_array(*chain_hash.as_bytes())
};
let bdk_wallet = electrum::wallet::BdkWallet::new(
&config.main_descriptor,
genesis_hash,
tip,
&coins,
&txs,
receive_index,
change_index,
);
let electrum = Electrum::new(client, bdk_wallet).map_err(StartupError::Electrum)?;
electrum
.sanity_checks(&genesis_hash)
.map_err(StartupError::Electrum)?;
Ok(electrum)
}
#[derive(Clone)]
pub struct DaemonControl {
config: Config,
@ -407,6 +482,9 @@ impl DaemonHandle {
sync::Mutex::from(bitcoind.expect("bitcoind must have been set already")),
)
as sync::Arc<sync::Mutex<dyn BitcoinInterface>>,
(None, Some(config::BitcoinBackend::Electrum(..))) => {
sync::Arc::from(sync::Mutex::from(setup_electrum(&config, db.clone())?))
}
(None, None) => Err(StartupError::MissingBitcoinBackendConfig)?,
};