bark-wallet 0.1.4

Wallet library and CLI for the bitcoin Ark protocol built by Second
Documentation
//! Fee estimation for various wallet operations.

use anyhow::Context;
use bitcoin::Amount;

use ark::{Vtxo, VtxoId};
use ark::fees::VtxoFeeInfo;

use crate::Wallet;

/// Result of a fee estimation containing the total cost, fee amount, and VTXOs used. It's very
/// important to consider that fees can change over time, so you should expect to renew this
/// estimate frequently when presenting this information to users.
#[derive(Debug, Clone)]
pub struct FeeEstimate {
	/// The total amount including fees.
	pub gross_amount: Amount,
	/// The fee amount charged by the server.
	pub fee: Amount,
	/// The amount excluding fees. For sends, this is the amount the recipient
	/// receives. For receives, this is the amount the user gets.
	pub net_amount: Amount,
	/// The VTXOs that would be used for this operation, if necessary.
	pub vtxos_spent: Vec<VtxoId>,
}

impl FeeEstimate {
	pub fn new(
		gross_amount: Amount,
		fee: Amount,
		net_amount: Amount,
		vtxos_spent: Vec<VtxoId>,
	) -> Self {
		Self {
			gross_amount,
			fee,
			net_amount,
			vtxos_spent,
		}
	}
}

impl Wallet {
	/// Estimate fees for a board operation. `FeeEstimate::net_amount` will be the amount of the
	/// newly boarded VTXO. Note: This doesn't include the onchain cost of creating the chain
	/// anchor transaction.
	pub async fn estimate_board_offchain_fee(
		&self,
		board_amount: Amount,
	) -> anyhow::Result<FeeEstimate> {
		let (_, ark_info) = self.require_server().await?;

		if board_amount < ark_info.min_board_amount {
			bail!("board amount of {} does not meet minimum value of {}",
				board_amount, ark_info.min_board_amount,
			);
		}
		if let Some(max) = ark_info.max_vtxo_amount {
			if board_amount > max {
				bail!("board amount of {} exceeds maximum value of {}", board_amount, max);
			}
		}

		let fee = ark_info.fees.board.calculate(board_amount).context("fee overflowed")?;
		let net_amount = board_amount.checked_sub(fee).unwrap_or(Amount::ZERO);

		Ok(FeeEstimate::new(board_amount, fee, net_amount, vec![]))
	}

	/// Estimate fees for an arkoor payment operation. Currently, this is a no-op as the server
	/// does not charge any fees for arkoor payments.
	pub async fn estimate_arkoor_payment_fee(&self, amount: Amount) -> anyhow::Result<FeeEstimate> {
		let zero_fee = Amount::ZERO;
		let inputs = match self.select_vtxos_to_cover(amount).await {
			Ok(inputs) => inputs,
			Err(_) => {
				// We choose to ignore every error, even those which are not due to insufficient
				// funds.
				vec![]
			},
		};

		let vtxo_ids = inputs.into_iter().map(|v| v.id()).collect();
		Ok(FeeEstimate::new(amount, zero_fee, amount, vtxo_ids))
	}

	/// Estimate fees for a lightning receive operation. `FeeEstimate::gross_amount` is the
	/// lightning payment amount, `FeeEstimate::net_amount` is how much the end user will receive.
	pub async fn estimate_lightning_receive_fee(
		&self,
		amount: Amount,
	) -> anyhow::Result<FeeEstimate> {
		let (_, ark_info) = self.require_server().await?;

		if let Some(max) = ark_info.max_vtxo_amount {
			if amount > max {
				bail!("amount of {} exceeds maximum value of {}", amount, max);
			}
		}

		let fee = ark_info.fees.lightning_receive.calculate(amount).context("fee overflowed")?;
		let net_amount = amount.checked_sub(fee).unwrap_or(Amount::ZERO);

		Ok(FeeEstimate::new(amount, fee, net_amount, vec![]))
	}

	/// Estimate fees for a lightning send operation. `FeeEstimate::net_amount` is the amount to be
	/// paid to a given invoice/address.
	///
	/// Uses the same iterative approach as `make_lightning_payment` to account for
	/// VTXO expiry-based fees.
	///
	/// If the wallet is lacking enough funds to send `amount` via lightning, then the estimate will
	/// be the maximum possible fee, assuming the user acquires enough funds to cover the payment.
	pub async fn estimate_lightning_send_fee(&self, amount: Amount) -> anyhow::Result<FeeEstimate> {
		let (_, ark_info) = self.require_server().await?;

		let (inputs, fee) = match self.select_vtxos_to_cover_with_fee(
			amount, |a, v| ark_info.fees.lightning_send.calculate(a, v).context("fee overflowed"),
		).await {
			Ok((inputs, fee)) => (inputs, fee),
			Err(_) => {
				// We choose to ignore every error, even those which are not due to insufficient
				// funds.
				let info = [VtxoFeeInfo { amount, expiry_blocks: u32::MAX }];
				let fee = ark_info.fees.lightning_send.calculate(amount, info)
					.context("fee overflowed")?;
				(Vec::new(), fee)
			},
		};
		let total_cost = amount.checked_add(fee).unwrap_or(Amount::MAX);
		let vtxo_ids = inputs.into_iter().map(|v| v.id()).collect();

		Ok(FeeEstimate::new(total_cost, fee, amount, vtxo_ids))
	}

	/// Estimate fees for an offboard operation. `FeeEstimate::net_amount` is the onchain amount the
	/// user can expect to receive by offboarding `FeeEstimate::vtxos_used`.
	pub async fn estimate_offboard<G>(
		&self,
		address: &bitcoin::Address,
		vtxos: impl IntoIterator<Item = impl AsRef<Vtxo<G>>>,
	) -> anyhow::Result<FeeEstimate> {
		let (_, ark_info) = self.require_server().await?;
		let script_buf = address.script_pubkey();
		let current_height = self.chain.tip().await?;

		let vtxos = vtxos.into_iter();
		let capacity = vtxos.size_hint().1.unwrap_or(vtxos.size_hint().0);
		let mut vtxo_ids = Vec::with_capacity(capacity);
		let mut fee_info = Vec::with_capacity(capacity);
		let mut amount = Amount::ZERO;
		for vtxo in vtxos {
			let vtxo = vtxo.as_ref();
			vtxo_ids.push(vtxo.id());
			fee_info.push(VtxoFeeInfo::from_vtxo_and_tip(vtxo, current_height));
			amount = amount + vtxo.amount();
		}

		let fee = ark_info.fees.offboard.calculate(
			&script_buf,
			amount,
			ark_info.offboard_feerate,
			fee_info,
		).context("Error whilst calculating offboard fee")?;

		let net_amount = amount.checked_sub(fee).unwrap_or(Amount::ZERO);
		Ok(FeeEstimate::new(amount, fee, net_amount, vtxo_ids))
	}

	/// Estimate fees for offboarding the entire Ark balance to a given address.
	/// Uses the same fee calculation as `offboard_all`.
	pub async fn estimate_offboard_all(
		&self,
		address: &bitcoin::Address,
	) -> anyhow::Result<FeeEstimate> {
		let vtxos = self.spendable_vtxos().await?;
		self.estimate_offboard(address, &vtxos).await
	}

	/// Estimate fees for a refresh operation (round participation). `FeeEstimate::net_amount` is
	/// the sum of the newly refreshed VTXOs.
	pub async fn estimate_refresh_fee<G>(
		&self,
		vtxos: impl IntoIterator<Item = impl AsRef<Vtxo<G>>>,
	) -> anyhow::Result<FeeEstimate> {
		let (_, ark_info) = self.require_server().await?;
		let current_height = self.chain.tip().await?;

		let vtxos = vtxos.into_iter();
		let capacity = vtxos.size_hint().1.unwrap_or(vtxos.size_hint().0);
		let mut vtxo_ids = Vec::with_capacity(capacity);
		let mut vtxo_fee_infos = Vec::with_capacity(capacity);
		let mut total_amount = Amount::ZERO;
		for vtxo in vtxos.into_iter() {
			let vtxo = vtxo.as_ref();
			vtxo_ids.push(vtxo.id());
			vtxo_fee_infos.push(VtxoFeeInfo::from_vtxo_and_tip(vtxo, current_height));
			total_amount = total_amount + vtxo.amount();
		}

		if let Some(max) = ark_info.max_vtxo_amount {
			if total_amount > max {
				bail!("total refresh amount of {} exceeds maximum value of {}", total_amount, max);
			}
		}

		// Calculate refresh fees
		let fee = ark_info.fees.refresh.calculate(vtxo_fee_infos).context("fee overflowed")?;
		let output_amount = total_amount.checked_sub(fee).unwrap_or(Amount::ZERO);
		Ok(FeeEstimate::new(total_amount, fee, output_amount, vtxo_ids))
	}

	/// Estimate fees for a send-onchain operation. `FeeEstimate::net_amount` is the onchain amount
	/// the user will receive and `FeeEstimate::gross_amount` is the offchain amount the user will
	/// pay using `FeeEstimate::vtxos_used`.
	///
	/// Uses the same iterative approach as `send_onchain` to account for VTXO expiry-based fees.
	///
	/// If the wallet is lacking enough funds to send `amount` onchain, then the estimate will be
	/// the maximum possible fee, assuming the user acquires enough funds to cover the payment.
	pub async fn estimate_send_onchain(
		&self,
		address: &bitcoin::Address,
		amount: Amount,
	) -> anyhow::Result<FeeEstimate> {
		let (_, ark_info) = self.require_server().await?;
		let script_buf = address.script_pubkey();

		let (inputs, fee) = match self.select_vtxos_to_cover_with_fee(
			amount, |a, v|
				ark_info.fees.offboard.calculate(&script_buf, a, ark_info.offboard_feerate, v)
					.ok_or_else(|| anyhow!("Error whilst calculating fee"))
		).await {
			Ok((inputs, fee)) => (inputs, fee),
			Err(_) => {
				// We choose to ignore every error, even those which are not due to insufficient
				// funds.
				let info = [VtxoFeeInfo { amount, expiry_blocks: u32::MAX }];
				let fee = ark_info.fees.offboard.calculate(
					&script_buf, amount, ark_info.offboard_feerate, info,
				).context("fee overflowed")?;
				(Vec::new(), fee)
			}
		};

		let total_cost = amount.checked_add(fee).unwrap_or(Amount::MAX);
		let vtxo_ids = inputs.into_iter().map(|v| v.id()).collect();

		Ok(FeeEstimate::new(total_cost, fee, amount, vtxo_ids))
	}
}