Skip to main content

bark/exit/
estimate.rs

1//! Fee estimation for emergency (unilateral) exits.
2//!
3//! Exiting a VTXO unilaterally has two distinct onchain costs:
4//!
5//! - **exit broadcast**: every not-yet-confirmed transaction in the VTXO's tree chain is a
6//!   zero-fee transaction carrying a P2A anchor, so each must be CPFP-bumped to confirm. This is
7//!   the dominant, time-critical cost and is funded from the wallet's confirmed onchain UTXOs.
8//! - **claim/drain**: once the exit outputs mature past their CSV delta they are swept to an
9//!   onchain address with a single batched transaction whose fee comes out of the recovered value.
10//!
11//! [`Exit::estimate_emergency_exit_fee`] reports both as a breakdown for a set of VTXOs, reflecting
12//! the current chain state (already-confirmed tree transactions cost nothing).
13
14use std::collections::{HashMap, HashSet};
15
16use bitcoin::transaction::{predict_weight, InputWeightPrediction};
17use bitcoin::{
18	Address, Amount, FeeRate, Sequence, Transaction, TxIn, TxOut, Weight, Witness, ScriptBuf,
19	sighash,
20};
21use bitcoin::secp256k1::{Secp256k1, SecretKey};
22
23use ark::Vtxo;
24use ark::vtxo::Full;
25use ark::vtxo::policy::signing::VtxoSigner;
26use bitcoin_ext::TxStatus;
27
28use ark::VtxoId;
29
30use crate::Wallet;
31use crate::exit::bdk::should_rbf;
32use crate::exit::{Exit, ExitError, ExitState, ExitTxStatus};
33use crate::onchain::MakeCpfpFees;
34
35/// A breakdown of the estimated onchain cost of unilaterally exiting a set of VTXOs.
36///
37/// `exit_broadcast_fee` is paid now from confirmed onchain funds; `claim_fee` is paid later
38/// out of the recovered value. Use [ExitFeeEstimate::total] for the sum. See `fee_rate` for how
39/// each leg is priced.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ExitFeeEstimate {
42	/// The total fees required to broadcast every not-yet-confirmed exit transaction.
43	///
44	/// This is the minimum offchain-balance required to pay for an emergency exit.
45	pub exit_broadcast_fee: Amount,
46	/// Fee for transaction that drains the exit outputs. It is substracted from
47	/// the exited VTXO amount.
48	pub claim_fee: Amount,
49	/// The fee rate used to price the exit-broadcast (CPFP) leg. Unless an explicit fee rate was
50	/// supplied, the claim leg is priced separately at the chain's `regular` rate, so this is not
51	/// necessarily the rate behind `claim_fee`.
52	pub fee_rate: FeeRate,
53	/// The number of exit transactions that still need to be broadcast and CPFP-bumped.
54	pub txs_to_broadcast: usize,
55	/// Whether the wallet's current confirmed onchain balance covers the full exit-broadcast walk.
56	///
57	/// A unilateral exit is funded serially across confirmed UTXOs (a CPFP child can only spend
58	/// confirmed coins, so each bump's change must confirm before it can fund the next one). An
59	/// exit can therefore stall midway if confirmed funds run short even when a single per-step fee
60	/// looks affordable. The walk is simulated bump by bump against a replica of the wallet;
61	/// `false` means confirmed funds ran out partway through it.
62	pub fundable: bool,
63}
64
65impl ExitFeeEstimate {
66	/// The total estimated cost: `exit_broadcast_fee + claim_fee`.
67	pub fn total(&self) -> Amount {
68		self.exit_broadcast_fee + self.claim_fee
69	}
70}
71
72impl Exit {
73	/// Estimate the onchain fees needed to unilaterally exit the given VTXOs.
74	///
75	/// The result takes the current chain state into account: any exit transactions
76	/// that are already confirmed onchain have no extra cost.
77	///
78	/// # Parameters
79	///  - `fee_rate` applies to both the broadcast and claim. If not provided, the
80	/// broadcast txs will use *fast* fee rate, and the claim one will use the *regular*
81	/// rate.
82	///  - `destination` influences only the claim transaction weight. If not set, a dummy P2TR address
83	/// for the wallet's network is used.
84	///
85	/// # Errors
86	/// - [ExitError::UnknownVtxo] if a VTXO id isn't known to the wallet.
87	/// - [ExitError::DustLimit] if a VTXO is below the dust limit (it can't be exited).
88	/// - [ExitError::VtxoAlreadyExited] if a VTXO has already completed its exit.
89	/// - [ExitError::VtxoAlreadySpent] if a VTXO was already spent (e.g. forfeited in a round).
90	pub async fn estimate_emergency_exit_fee(
91		&self,
92		vtxos: &[VtxoId],
93		wallet: &Wallet,
94		fee_rate: Option<FeeRate>,
95		destination: Option<Address>,
96	) -> anyhow::Result<ExitFeeEstimate, ExitError> {
97		let (broadcast_fee_rate, claim_fee_rate) = match fee_rate {
98			Some(fr) => (fr, fr),
99			None => (
100				self.default_exit_fee_rate().await,
101				wallet.chain().fee_rates().await.regular,
102			),
103		};
104
105		// Resolve each VTXO, collecting (in chain order) the exit transactions that still need
106		// broadcasting plus the full VTXOs we'll drain.
107		let mut full_vtxos = HashMap::with_capacity(vtxos.len());
108		let mut seen = HashSet::new();
109		let mut unconfirmed_parents = Vec::new();
110		let mut pending_status = Vec::new();
111
112		{
113			let guard = self.inner.read().await;
114			for &vtxo_id in vtxos {
115				if full_vtxos.contains_key(&vtxo_id) {
116					continue;
117				}
118
119				let vtxo = wallet.inner.db.get_full_vtxo(vtxo_id).await
120					.map_err(|e| ExitError::InvalidWalletState { error: e.to_string() })?
121					.ok_or(ExitError::UnknownVtxo { vtxo: vtxo_id })?;
122
123				if let Err(error) = vtxo.check_standard() {
124					return Err(ExitError::NonStandardVtxo { vtxo: vtxo_id, error }.into());
125				}
126
127				match guard.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id).map(|ev| ev.state()) {
128					Some(ExitState::Claimed(_)) => {
129						return Err(ExitError::VtxoAlreadyExited { vtxo: vtxo_id });
130					},
131					// The VTXO was consumed by something other than this exit (e.g. forfeited in a
132					// round), so no exit transactions can be broadcast — there's nothing to price.
133					Some(ExitState::VtxoAlreadySpent(_)) => {
134						return Err(ExitError::VtxoAlreadySpent { vtxo: vtxo_id });
135					},
136					Some(ExitState::Processing(s)) => {
137						// An in-progress exit: confirmed transactions cost nothing. A package
138						// already in the mempool is priced as an RBF replacement of its current
139						// child at the requested rate — or skipped when the child's committed fee
140						// can't be determined — and everything else needs a fresh wallet-funded
141						// bump. Confirmation is read from tracked state, so no chain query is
142						// needed here.
143						for exit_tx in &s.transactions {
144							let fees = match &exit_tx.status {
145								ExitTxStatus::Confirmed { .. } => continue,
146								ExitTxStatus::AwaitingConfirmation { .. } => {
147									match guard.tx_manager.get_child_status(exit_tx.txid).await {
148										Ok(Some(c)) => match c.fee_info {
149											// If the tx is already in the mempool and we don't need to RBF it, we can just skip it.
150											Some(fi) if should_rbf(broadcast_fee_rate, fi.fee_rate) => {
151												MakeCpfpFees::Rbf {
152													min_effective_fee_rate: broadcast_fee_rate,
153													current_package_fee: fi.total_fee,
154												}
155											},
156											_ => continue,
157										},
158										_ => continue,
159									}
160								},
161								ExitTxStatus::VerifyInputs |
162								ExitTxStatus::AwaitingCpfpBroadcast |
163								ExitTxStatus::AwaitingInputConfirmation { .. } => {
164									MakeCpfpFees::Effective(broadcast_fee_rate)
165								},
166							};
167							if !seen.insert(exit_tx.txid) {
168								continue;
169							}
170							let package = guard.tx_manager.get_package(exit_tx.txid)?;
171							let tx = package.read().await.exit.tx.clone();
172							unconfirmed_parents.push((tx, fees));
173						}
174					},
175					// All exit txs are confirmed, so there's nothing left to broadcast but the claim.
176					Some(ExitState::AwaitingDelta(_)) |
177					Some(ExitState::Claimable(_)) |
178					Some(ExitState::ClaimInProgress(_)) => {},
179					// Exit not started: price the whole tree, deferring the per-tx confirmation
180					// check until after we drop the lock.
181					Some(ExitState::Start(_)) | Some(ExitState::Canceled(_)) | None => {
182						for item in vtxo.transactions() {
183							pending_status.push(item.tx);
184						}
185					},
186				}
187
188				full_vtxos.insert(vtxo_id, vtxo);
189			}
190		}
191
192		// Now query the chain for the not-yet-started candidates, skipping any already confirmed
193		// onchain (they cost nothing) and any duplicate shared txid.
194		for tx in pending_status {
195			let txid = tx.compute_txid();
196			if seen.contains(&txid) {
197				continue;
198			}
199			let mut guard = self.inner.write().await;
200			let status = guard.tx_manager.tx_status(txid).await
201				.map_err(|e| ExitError::TransactionRetrievalFailure { txid, error: e.to_string() })?;
202
203			let rbf = match status {
204				TxStatus::NotFound => {
205					MakeCpfpFees::Effective(broadcast_fee_rate)
206				},
207				TxStatus::Mempool => {
208					match guard.tx_manager.get_child_status(txid).await {
209						Ok(Some(c)) => c.fee_info.map(|f| MakeCpfpFees::Rbf {
210							min_effective_fee_rate: broadcast_fee_rate,
211							current_package_fee: f.total_fee,
212						}),
213						_ => None,
214					}.unwrap_or(MakeCpfpFees::Effective(broadcast_fee_rate))
215				},
216				TxStatus::Confirmed(_) => {
217					continue;
218				},
219			};
220
221			seen.insert(txid);
222			unconfirmed_parents.push((tx, rbf));
223		}
224
225		// CPFP-bump every unconfirmed exit transaction.
226		let txs_to_broadcast = unconfirmed_parents.len();
227		let (children, fundable) = match wallet.onchain() {
228			Some(onchain) => {
229				let walk = onchain.read().await
230					.estimate_p2a_cpfp_walk(&unconfirmed_parents)
231					.map_err(|e| ExitError::InternalError { error: e.to_string() })?;
232
233				// The walk funds each child from confirmed coins, recycling change exactly like the real
234				// serial broadcast, so it completing is the precise version of "the confirmed balance
235				// covers the whole walk".
236				let fundable = walk.shortfall.is_none();
237
238				(walk.children, fundable)
239			},
240			None => (vec![], false)
241		};
242
243		let mut exit_broadcast_fee = children.iter().map(|(_, fee)| *fee).sum::<Amount>();
244		// The walk stops when confirmed funds run out; each remaining parent is then priced with
245		// a canonical one-input P2TR-funded child at the requested rate.
246		for (parent, _) in unconfirmed_parents.iter().skip(children.len()) {
247			exit_broadcast_fee += broadcast_fee_rate * (parent.weight() + canonical_cpfp_child_weight());
248		}
249
250		// a single batched drain of every VTXO to one destination.
251		let vtxos = full_vtxos.into_iter().map(|(_, vtxo)| vtxo).collect::<Vec<_>>();
252		let claim_fee = self.estimate_claim_fee(&vtxos, wallet, claim_fee_rate, destination).await?;
253
254		Ok(ExitFeeEstimate {
255			exit_broadcast_fee,
256			claim_fee,
257			fee_rate: broadcast_fee_rate,
258			txs_to_broadcast,
259			fundable,
260		})
261	}
262
263	/// Builds the batched drain transaction for the given VTXOs and returns `fee_rate * weight`.
264	///
265	/// Mirrors the weight path of [Exit::drain_exits] but signs through the wallet's [VtxoSigner]
266	/// directly, since VTXOs are most often not claimable yet when estimating.
267	async fn estimate_claim_fee(
268		&self,
269		vtxos: &[Vtxo<Full>],
270		wallet: &Wallet,
271		fee_rate: FeeRate,
272		destination: Option<Address>,
273	) -> anyhow::Result<Amount, ExitError> {
274		if vtxos.is_empty() {
275			return Ok(Amount::ZERO);
276		}
277
278		let address = match destination {
279			Some(a) => a,
280			None => placeholder_p2tr_address(wallet).await?,
281		};
282
283		let tip = wallet.chain().tip().await
284			.map_err(|e| ExitError::TipRetrievalFailure { error: e.to_string() })?;
285		let locktime = bitcoin::absolute::LockTime::from_height(tip)
286			.map_err(|e| ExitError::InvalidLocktime { tip, error: e.to_string() })?;
287
288		let mut output_amount = Amount::ZERO;
289		let mut tx_ins = Vec::with_capacity(vtxos.len());
290		for vtxo in vtxos {
291			let clause = wallet.find_signable_clause(vtxo).await
292				.ok_or(ExitError::ClaimMissingSignableClause { vtxo: vtxo.id() })?;
293			output_amount += vtxo.amount();
294			tx_ins.push(TxIn {
295				previous_output: vtxo.point(),
296				script_sig: ScriptBuf::default(),
297				sequence: clause.sequence().unwrap_or(Sequence::ZERO),
298				witness: Witness::new(),
299			});
300		}
301
302		let mut tx = Transaction {
303			version: bitcoin::transaction::Version::TWO,
304			lock_time: locktime,
305			input: tx_ins,
306			output: vec![TxOut { script_pubkey: address.script_pubkey(), value: output_amount }],
307		};
308
309		// Sign each input to get a correctly-sized witness, then read off the weight. Signing
310		// borrows the transaction, so collect the witnesses first and apply them afterwards.
311		let prevouts = vtxos.iter().map(|v| v.txout()).collect::<Vec<_>>();
312		let prevouts = sighash::Prevouts::All(&prevouts);
313		let mut witnesses = Vec::with_capacity(vtxos.len());
314		{
315			let mut shc = sighash::SighashCache::new(&tx);
316			for (i, vtxo) in vtxos.iter().enumerate() {
317				let witness = wallet.sign_input(vtxo, i, &mut shc, &prevouts).await
318					.map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
319				witnesses.push(witness);
320			}
321		}
322		for (input, witness) in tx.input.iter_mut().zip(witnesses) {
323			input.witness = witness;
324		}
325
326		Ok(fee_rate * tx.weight())
327	}
328}
329
330/// The weight of a canonical CPFP child: one P2A anchor input, one P2TR key-spend funding input,
331/// and one P2TR change output.
332fn canonical_cpfp_child_weight() -> Weight {
333	const P2TR_SPK_LEN: usize = 34;
334	predict_weight(
335		[
336			// P2A anchor spend: empty scriptSig, empty witness.
337			InputWeightPrediction::new(0, [0usize; 0]),
338			InputWeightPrediction::P2TR_KEY_DEFAULT_SIGHASH,
339		],
340		[P2TR_SPK_LEN],
341	)
342}
343
344/// A throwaway P2TR address on the wallet's network, used only to weigh the drain output.
345async fn placeholder_p2tr_address(wallet: &Wallet) -> anyhow::Result<Address, ExitError> {
346	let network = wallet.network().await
347		.map_err(|e| ExitError::InternalError { error: e.to_string() })?;
348	let secp = Secp256k1::new();
349	let sk = SecretKey::from_slice(&[1u8; 32]).expect("valid secret key");
350	let (xonly, _) = sk.public_key(&secp).x_only_public_key();
351	Ok(Address::p2tr(&secp, xonly, None, network))
352}
353
354#[cfg(test)]
355mod test {
356	use super::*;
357
358	#[test]
359	fn canonical_child_weight_is_plausible() {
360		// A 2-input (anchor + P2TR key spend), 1-P2TR-output v3 child is on the order of
361		// ~110-160 vbytes; assert we land in a sane band rather than e.g. zero or a wild value.
362		let w = canonical_cpfp_child_weight();
363		assert!(w > Weight::from_vb_unchecked(90), "child weight too small: {}", w);
364		assert!(w < Weight::from_vb_unchecked(200), "child weight too large: {}", w);
365	}
366}