use std::borrow::Borrow;
use std::collections::HashSet;
use anyhow::Context;
use bitcoin::FeeRate;
use log::{debug, warn};
use ark::VtxoId;
use bitcoin_ext::{BlockDelta, BlockHeight, P2TR_DUST};
use crate::Wallet;
use crate::exit::progress::util::estimate_exit_cost;
use crate::vtxo::state::{VtxoStateKind, WalletVtxo};
const SOFT_REFRESH_EXPIRY_THRESHOLD: BlockDelta = 28;
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait FilterVtxos: Send + Sync {
async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool>;
async fn filter_vtxos<V: Borrow<WalletVtxo> + Send>(&self, vtxos: &mut Vec<V>) -> anyhow::Result<()> {
for i in (0..vtxos.len()).rev() {
if !self.matches(vtxos[i].borrow()).await? {
vtxos.swap_remove(i);
}
}
Ok(())
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl<F> FilterVtxos for F
where
F: Fn(&WalletVtxo) -> anyhow::Result<bool> + Send + Sync,
{
async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
self(vtxo)
}
}
pub struct VtxoFilter<'a> {
pub expires_before: Option<BlockHeight>,
pub counterparty: bool,
pub exclude: HashSet<VtxoId>,
pub include: HashSet<VtxoId>,
wallet: &'a Wallet,
}
impl<'a> VtxoFilter<'a> {
pub fn new(wallet: &'a Wallet) -> VtxoFilter<'a> {
VtxoFilter {
expires_before: None,
counterparty: false,
exclude: HashSet::new(),
include: HashSet::new(),
wallet,
}
}
pub fn expires_before(mut self, expires_before: BlockHeight) -> Self {
self.expires_before = Some(expires_before);
self
}
pub fn counterparty(mut self) -> Self {
self.counterparty = true;
self
}
pub fn exclude(mut self, exclude: VtxoId) -> Self {
self.exclude.insert(exclude);
self
}
pub fn exclude_many(mut self, exclude: impl IntoIterator<Item = VtxoId>) -> Self {
self.exclude.extend(exclude);
self
}
pub fn include(mut self, include: VtxoId) -> Self {
self.include.insert(include);
self
}
pub fn include_many(mut self, include: impl IntoIterator<Item = VtxoId>) -> Self {
self.include.extend(include);
self
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl FilterVtxos for VtxoFilter<'_> {
async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
let id = vtxo.id();
if self.include.contains(&id) {
return Ok(true);
}
if self.exclude.contains(&id) {
return Ok(false);
}
if let Some(height) = self.expires_before {
if (vtxo.expiry_height()) < height {
return Ok(true);
}
}
if self.counterparty {
if self.wallet.has_counterparty_risk(vtxo).await.context("db error")? {
return Ok(true);
}
}
Ok(false)
}
}
enum InnerRefreshStrategy {
MustRefresh,
ShouldRefreshInclusive,
ShouldRefreshExclusive,
ShouldRefreshIfMustRefresh,
}
pub struct RefreshStrategy<'a> {
inner: InnerRefreshStrategy,
tip: BlockHeight,
wallet: &'a Wallet,
fee_rate: FeeRate,
}
impl<'a> RefreshStrategy<'a> {
pub fn must_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
Self {
inner: InnerRefreshStrategy::MustRefresh,
tip,
wallet,
fee_rate,
}
}
pub fn should_refresh(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
Self {
inner: InnerRefreshStrategy::ShouldRefreshInclusive,
tip,
wallet,
fee_rate,
}
}
pub fn should_refresh_exclusive(
wallet: &'a Wallet,
tip: BlockHeight,
fee_rate: FeeRate,
) -> Self {
Self {
inner: InnerRefreshStrategy::ShouldRefreshExclusive,
tip,
wallet,
fee_rate,
}
}
pub fn should_refresh_if_must(wallet: &'a Wallet, tip: BlockHeight, fee_rate: FeeRate) -> Self {
Self {
inner: InnerRefreshStrategy::ShouldRefreshIfMustRefresh,
tip,
wallet,
fee_rate,
}
}
async fn server_max_arkoor_depth(&self) -> anyhow::Result<Option<u16>> {
Ok(self.wallet.ark_info().await?.map(|i| i.max_vtxo_exit_depth))
}
async fn check_must_refresh(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
if let Some(max_depth) = self.server_max_arkoor_depth().await? {
if vtxo.exit_depth() >= max_depth {
warn!(
"VTXO {} exit depth {} has reached the server maximum of {}; \
must be refreshed before further OOR payments are possible",
vtxo.id(), vtxo.exit_depth(), max_depth,
);
return Ok(true);
}
}
let threshold = self.wallet.config().vtxo_refresh_expiry_threshold;
if self.tip > vtxo.expiry_height() {
warn!("VTXO {} is expired, must be refreshed", vtxo.id());
return Ok(true)
} else if self.tip > vtxo.expiry_height().saturating_sub(threshold) {
debug!("VTXO {} is about to expire soon, must be refreshed", vtxo.id());
return Ok(true);
}
Ok(false)
}
async fn check_should_refresh_depth(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
if let Some(max_depth) = self.server_max_arkoor_depth().await? {
let soft_depth_threshold = max_depth / 2;
if vtxo.exit_depth() >= soft_depth_threshold {
warn!(
"VTXO {} exit depth {} is approaching the server maximum of {}; \
should be refreshed on next opportunity",
vtxo.id(), vtxo.exit_depth(), max_depth,
);
return Ok(true);
}
}
let soft_threshold = self.wallet.config().vtxo_refresh_expiry_threshold
+ SOFT_REFRESH_EXPIRY_THRESHOLD as u32;
if self.tip > vtxo.expiry_height().saturating_sub(soft_threshold) {
warn!("VTXO {} is about to expire, should be refreshed on next opportunity",
vtxo.id(),
);
return Ok(true);
}
let fr = self.fee_rate;
if vtxo.amount() < estimate_exit_cost(&[vtxo.vtxo.clone()], fr) {
warn!("VTXO {} is uneconomical to exit, should be refreshed on \
next opportunity", vtxo.id(),
);
return Ok(true);
}
if vtxo.amount() < P2TR_DUST {
warn!("VTXO {} is dust, should be refreshed on next opportunity", vtxo.id());
return Ok(true);
}
Ok(false)
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl FilterVtxos for RefreshStrategy<'_> {
async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
match self.inner {
InnerRefreshStrategy::MustRefresh => Ok(self.check_must_refresh(vtxo).await?),
InnerRefreshStrategy::ShouldRefreshInclusive => Ok(
self.check_must_refresh(vtxo).await? ||
self.check_should_refresh_depth(vtxo).await?
),
InnerRefreshStrategy::ShouldRefreshExclusive => Ok(
!self.check_must_refresh(vtxo).await? &&
self.check_should_refresh_depth(vtxo).await?
),
InnerRefreshStrategy::ShouldRefreshIfMustRefresh =>
bail!("FilterVtxos::matches called on RefreshStrategy::should_refresh_if_must"),
}
}
async fn filter_vtxos<V: Borrow<WalletVtxo> + Send>(
&self,
vtxos: &mut Vec<V>,
) -> anyhow::Result<()> {
match self.inner {
InnerRefreshStrategy::ShouldRefreshIfMustRefresh => {
let mut must_refresh = false;
for i in (0..vtxos.len()).rev() {
let keep = {
let vtxo = vtxos[i].borrow();
let is_must = self.check_must_refresh(vtxo).await?;
if is_must {
must_refresh = true;
true
} else {
self.check_should_refresh_depth(vtxo).await?
}
};
if !keep {
vtxos.swap_remove(i);
}
}
if !must_refresh {
vtxos.clear();
}
},
_ => {
for i in (0..vtxos.len()).rev() {
let vtxo = vtxos[i].borrow();
if !self.matches(vtxo).await? {
vtxos.swap_remove(i);
}
}
},
}
Ok(())
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl FilterVtxos for VtxoStateKind {
async fn matches(&self, vtxo: &WalletVtxo) -> anyhow::Result<bool> {
Ok(vtxo.state.kind() == *self)
}
}