Browse Source
This should greatly improve resilience as we no longer have any tasks within `tokio` that can fail without recovering. Instead, we periodically ping the actor with a `Sync` message which updates the local state of all scripts and sends out messages accordingly.upload-correct-windows-binary
Thomas Eizinger
3 years ago
4 changed files with 359 additions and 630 deletions
@ -1,429 +0,0 @@ |
|||
#![allow(dead_code)] |
|||
|
|||
use anyhow::{bail, Context, Result}; |
|||
use bdk::bitcoin::{Script, Txid}; |
|||
use bdk::electrum_client::{ElectrumApi, GetHistoryRes, HeaderNotification}; |
|||
use serde::{Deserialize, Serialize}; |
|||
use std::collections::{BTreeMap, HashMap}; |
|||
use std::convert::{TryFrom, TryInto}; |
|||
use std::fmt; |
|||
use std::ops::Add; |
|||
use std::sync::Arc; |
|||
use tokio::sync::{watch, Mutex}; |
|||
use tokio::time::{Duration, Instant}; |
|||
|
|||
pub struct Monitor { |
|||
client: Arc<Mutex<Client>>, |
|||
finality_confirmations: u32, |
|||
} |
|||
|
|||
impl Monitor { |
|||
pub fn new(electrum_rpc_url: &str, finality_confirmations: u32) -> Result<Self> { |
|||
let client = bdk::electrum_client::Client::new(electrum_rpc_url) |
|||
.context("Failed to initialize Electrum RPC client")?; |
|||
|
|||
let client = Client::new(client, Duration::from_secs(10))?; |
|||
|
|||
let monitor = Monitor { |
|||
client: Arc::new(Mutex::new(client)), |
|||
finality_confirmations, |
|||
}; |
|||
|
|||
Ok(monitor) |
|||
} |
|||
|
|||
pub async fn subscribe_to(&self, tx: impl Watchable + Send + 'static) -> Subscription { |
|||
let txid = tx.id(); |
|||
let script = tx.script(); |
|||
|
|||
let sub = self |
|||
.client |
|||
.lock() |
|||
.await |
|||
.subscriptions |
|||
.entry((txid, script.clone())) |
|||
.or_insert_with(|| { |
|||
let (sender, receiver) = watch::channel(ScriptStatus::Unseen); |
|||
let client = self.client.clone(); |
|||
|
|||
tokio::spawn(async move { |
|||
let mut last_status = None; |
|||
|
|||
// TODO: We need feedback in the monitoring actor about failures in here
|
|||
loop { |
|||
tokio::time::sleep(Duration::from_secs(5)).await; |
|||
|
|||
let new_status = match client.lock().await.status_of_script(&tx) { |
|||
Ok(new_status) => new_status, |
|||
Err(error) => { |
|||
tracing::warn!(%txid, "Failed to get status of script: {:#}", error); |
|||
return; |
|||
} |
|||
}; |
|||
|
|||
last_status = Some(print_status_change(txid, last_status, new_status)); |
|||
|
|||
let all_receivers_gone = sender.send(new_status).is_err(); |
|||
|
|||
if all_receivers_gone { |
|||
tracing::debug!(%txid, "All receivers gone, removing subscription"); |
|||
client.lock().await.subscriptions.remove(&(txid, script)); |
|||
return; |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Subscription { |
|||
receiver, |
|||
finality_confirmations: self.finality_confirmations, |
|||
txid, |
|||
} |
|||
}) |
|||
.clone(); |
|||
|
|||
sub |
|||
} |
|||
} |
|||
|
|||
/// Represents a subscription to the status of a given transaction.
|
|||
#[derive(Debug, Clone)] |
|||
pub struct Subscription { |
|||
receiver: watch::Receiver<ScriptStatus>, |
|||
finality_confirmations: u32, |
|||
txid: Txid, |
|||
} |
|||
|
|||
impl Subscription { |
|||
pub async fn wait_until_final(&self) -> Result<()> { |
|||
let conf_target = self.finality_confirmations; |
|||
let txid = self.txid; |
|||
|
|||
tracing::info!(%txid, required_confirmation=%conf_target, "Waiting for Bitcoin transaction finality"); |
|||
|
|||
let mut seen_confirmations = 0; |
|||
|
|||
self.wait_until(|status| match status { |
|||
ScriptStatus::Confirmed(inner) => { |
|||
let confirmations = inner.confirmations(); |
|||
|
|||
if confirmations > seen_confirmations { |
|||
tracing::info!(%txid, |
|||
seen_confirmations = %confirmations, |
|||
needed_confirmations = %conf_target, |
|||
"Waiting for Bitcoin transaction finality"); |
|||
seen_confirmations = confirmations; |
|||
} |
|||
|
|||
inner.meets_target(conf_target) |
|||
} |
|||
_ => false, |
|||
}) |
|||
.await |
|||
} |
|||
|
|||
pub async fn wait_until_seen(&self) -> Result<()> { |
|||
self.wait_until(ScriptStatus::has_been_seen).await |
|||
} |
|||
|
|||
pub async fn wait_until_confirmed_with<T>(&self, target: T) -> Result<()> |
|||
where |
|||
u32: PartialOrd<T>, |
|||
T: Copy, |
|||
{ |
|||
self.wait_until(|status| status.is_confirmed_with(target)) |
|||
.await |
|||
} |
|||
|
|||
async fn wait_until(&self, mut predicate: impl FnMut(&ScriptStatus) -> bool) -> Result<()> { |
|||
let mut receiver = self.receiver.clone(); |
|||
|
|||
while !predicate(&receiver.borrow()) { |
|||
receiver |
|||
.changed() |
|||
.await |
|||
.context("Failed while waiting for next status update")?; |
|||
} |
|||
|
|||
Ok(()) |
|||
} |
|||
} |
|||
|
|||
/// Defines a watchable transaction.
|
|||
///
|
|||
/// For a transaction to be watchable, we need to know two things: Its
|
|||
/// transaction ID and the specific output script that is going to change.
|
|||
/// A transaction can obviously have multiple outputs but our protocol purposes,
|
|||
/// we are usually interested in a specific one.
|
|||
pub trait Watchable { |
|||
fn id(&self) -> Txid; |
|||
fn script(&self) -> Script; |
|||
} |
|||
|
|||
impl Watchable for (Txid, Script) { |
|||
fn id(&self) -> Txid { |
|||
self.0 |
|||
} |
|||
|
|||
fn script(&self) -> Script { |
|||
self.1.clone() |
|||
} |
|||
} |
|||
|
|||
fn print_status_change(txid: Txid, old: Option<ScriptStatus>, new: ScriptStatus) -> ScriptStatus { |
|||
match (old, new) { |
|||
(None, new_status) => { |
|||
tracing::debug!(%txid, status = %new_status, "Found relevant Bitcoin transaction"); |
|||
} |
|||
(Some(old_status), new_status) if old_status != new_status => { |
|||
tracing::debug!(%txid, %new_status, %old_status, "Bitcoin transaction status changed"); |
|||
} |
|||
_ => {} |
|||
} |
|||
|
|||
new |
|||
} |
|||
|
|||
pub struct Client { |
|||
electrum: bdk::electrum_client::Client, |
|||
latest_block_height: BlockHeight, |
|||
last_sync: Instant, |
|||
sync_interval: Duration, |
|||
script_history: BTreeMap<Script, Vec<GetHistoryRes>>, |
|||
subscriptions: HashMap<(Txid, Script), Subscription>, |
|||
} |
|||
|
|||
impl Client { |
|||
fn new(electrum: bdk::electrum_client::Client, interval: Duration) -> Result<Self> { |
|||
// Initially fetch the latest block for storing the height.
|
|||
// We do not act on this subscription after this call.
|
|||
let latest_block = electrum |
|||
.block_headers_subscribe() |
|||
.context("Failed to subscribe to header notifications")?; |
|||
|
|||
Ok(Self { |
|||
electrum, |
|||
latest_block_height: BlockHeight::try_from(latest_block)?, |
|||
last_sync: Instant::now(), |
|||
sync_interval: interval, |
|||
script_history: Default::default(), |
|||
subscriptions: Default::default(), |
|||
}) |
|||
} |
|||
|
|||
fn update_state(&mut self) -> Result<()> { |
|||
let now = Instant::now(); |
|||
if now < self.last_sync + self.sync_interval { |
|||
return Ok(()); |
|||
} |
|||
|
|||
self.last_sync = now; |
|||
self.update_latest_block()?; |
|||
self.update_script_histories()?; |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
fn status_of_script<T>(&mut self, tx: &T) -> Result<ScriptStatus> |
|||
where |
|||
T: Watchable, |
|||
{ |
|||
let txid = tx.id(); |
|||
let script = tx.script(); |
|||
|
|||
if !self.script_history.contains_key(&script) { |
|||
self.script_history.insert(script.clone(), vec![]); |
|||
} |
|||
|
|||
self.update_state()?; |
|||
|
|||
let history = self.script_history.entry(script).or_default(); |
|||
|
|||
let history_of_tx = history |
|||
.iter() |
|||
.filter(|entry| entry.tx_hash == txid) |
|||
.collect::<Vec<_>>(); |
|||
|
|||
match history_of_tx.as_slice() { |
|||
[] => Ok(ScriptStatus::Unseen), |
|||
[remaining @ .., last] => { |
|||
if !remaining.is_empty() { |
|||
tracing::warn!("Found more than a single history entry for script. This is highly unexpected and those history entries will be ignored") |
|||
} |
|||
|
|||
if last.height <= 0 { |
|||
Ok(ScriptStatus::InMempool) |
|||
} else { |
|||
Ok(ScriptStatus::Confirmed( |
|||
Confirmed::from_inclusion_and_latest_block( |
|||
u32::try_from(last.height)?, |
|||
u32::from(self.latest_block_height), |
|||
), |
|||
)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
fn update_latest_block(&mut self) -> Result<()> { |
|||
// Fetch the latest block for storing the height.
|
|||
// We do not act on this subscription after this call, as we cannot rely on
|
|||
// subscription push notifications because eventually the Electrum server will
|
|||
// close the connection and subscriptions are not automatically renewed
|
|||
// upon renewing the connection.
|
|||
let latest_block = self |
|||
.electrum |
|||
.block_headers_subscribe() |
|||
.context("Failed to subscribe to header notifications")?; |
|||
let latest_block_height = BlockHeight::try_from(latest_block)?; |
|||
|
|||
if latest_block_height > self.latest_block_height { |
|||
tracing::debug!( |
|||
block_height = u32::from(latest_block_height), |
|||
"Got notification for new block" |
|||
); |
|||
self.latest_block_height = latest_block_height; |
|||
} |
|||
|
|||
Ok(()) |
|||
} |
|||
|
|||
fn update_script_histories(&mut self) -> Result<()> { |
|||
let histories = self |
|||
.electrum |
|||
.batch_script_get_history(self.script_history.keys()) |
|||
.context("Failed to get script histories")?; |
|||
|
|||
if histories.len() != self.script_history.len() { |
|||
bail!( |
|||
"Expected {} history entries, received {}", |
|||
self.script_history.len(), |
|||
histories.len() |
|||
); |
|||
} |
|||
|
|||
let scripts = self.script_history.keys().cloned(); |
|||
let histories = histories.into_iter(); |
|||
|
|||
self.script_history = scripts.zip(histories).collect::<BTreeMap<_, _>>(); |
|||
|
|||
Ok(()) |
|||
} |
|||
} |
|||
|
|||
/// Represent a block height, or block number, expressed in absolute block
|
|||
/// count. E.g. The transaction was included in block #655123, 655123 block
|
|||
/// after the genesis block.
|
|||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)] |
|||
#[serde(transparent)] |
|||
pub struct BlockHeight(u32); |
|||
|
|||
impl From<BlockHeight> for u32 { |
|||
fn from(height: BlockHeight) -> Self { |
|||
height.0 |
|||
} |
|||
} |
|||
|
|||
impl TryFrom<HeaderNotification> for BlockHeight { |
|||
type Error = anyhow::Error; |
|||
|
|||
fn try_from(value: HeaderNotification) -> Result<Self, Self::Error> { |
|||
Ok(Self( |
|||
value |
|||
.height |
|||
.try_into() |
|||
.context("Failed to fit usize into u32")?, |
|||
)) |
|||
} |
|||
} |
|||
|
|||
impl Add<u32> for BlockHeight { |
|||
type Output = BlockHeight; |
|||
fn add(self, rhs: u32) -> Self::Output { |
|||
BlockHeight(self.0 + rhs) |
|||
} |
|||
} |
|||
|
|||
#[derive(Debug, Clone, Copy, PartialEq)] |
|||
pub enum ExpiredTimelocks { |
|||
None, |
|||
Cancel, |
|||
Punish, |
|||
} |
|||
|
|||
#[derive(Debug, Copy, Clone, PartialEq)] |
|||
pub struct Confirmed { |
|||
/// The depth of this transaction within the blockchain.
|
|||
///
|
|||
/// Will be zero if the transaction is included in the latest block.
|
|||
depth: u32, |
|||
} |
|||
|
|||
impl Confirmed { |
|||
pub fn new(depth: u32) -> Self { |
|||
Self { depth } |
|||
} |
|||
|
|||
/// Compute the depth of a transaction based on its inclusion height and the
|
|||
/// latest known block.
|
|||
///
|
|||
/// Our information about the latest block might be outdated. To avoid an
|
|||
/// overflow, we make sure the depth is 0 in case the inclusion height
|
|||
/// exceeds our latest known block,
|
|||
pub fn from_inclusion_and_latest_block(inclusion_height: u32, latest_block: u32) -> Self { |
|||
let depth = latest_block.saturating_sub(inclusion_height); |
|||
|
|||
Self { depth } |
|||
} |
|||
|
|||
pub fn confirmations(&self) -> u32 { |
|||
self.depth + 1 |
|||
} |
|||
|
|||
pub fn meets_target<T>(&self, target: T) -> bool |
|||
where |
|||
u32: PartialOrd<T>, |
|||
{ |
|||
self.confirmations() >= target |
|||
} |
|||
} |
|||
|
|||
#[derive(Debug, Copy, Clone, PartialEq)] |
|||
pub enum ScriptStatus { |
|||
Unseen, |
|||
InMempool, |
|||
Confirmed(Confirmed), |
|||
} |
|||
|
|||
impl ScriptStatus { |
|||
/// Check if the script has any confirmations.
|
|||
pub fn is_confirmed(&self) -> bool { |
|||
matches!(self, ScriptStatus::Confirmed(_)) |
|||
} |
|||
|
|||
/// Check if the script has met the given confirmation target.
|
|||
pub fn is_confirmed_with<T>(&self, target: T) -> bool |
|||
where |
|||
u32: PartialOrd<T>, |
|||
{ |
|||
match self { |
|||
ScriptStatus::Confirmed(inner) => inner.meets_target(target), |
|||
_ => false, |
|||
} |
|||
} |
|||
|
|||
pub fn has_been_seen(&self) -> bool { |
|||
matches!(self, ScriptStatus::InMempool | ScriptStatus::Confirmed(_)) |
|||
} |
|||
} |
|||
|
|||
impl fmt::Display for ScriptStatus { |
|||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|||
match self { |
|||
ScriptStatus::Unseen => write!(f, "unseen"), |
|||
ScriptStatus::InMempool => write!(f, "in mempool"), |
|||
ScriptStatus::Confirmed(inner) => { |
|||
write!(f, "confirmed with {} blocks", inner.confirmations()) |
|||
} |
|||
} |
|||
} |
|||
} |
Loading…
Reference in new issue