1mod models;
116mod vtxo;
117pub(crate) mod progress;
118pub(crate) mod transaction_manager;
119
120pub use self::models::{
121 ExitTransactionPackage, TransactionInfo, ChildTransactionInfo, ExitError, ExitState,
122 ExitTx, ExitTxStatus, ExitTxOrigin, ExitStartState, ExitProcessingState, ExitAwaitingDeltaState,
123 ExitClaimableState, ExitClaimInProgressState, ExitClaimedState, ExitProgressStatus,
124 ExitTransactionStatus,
125};
126pub use self::vtxo::ExitVtxo;
127
128use std::borrow::Borrow;
129use std::cmp;
130use std::collections::HashMap;
131use std::sync::Arc;
132
133use anyhow::Context;
134use bitcoin::{
135 Address, Amount, FeeRate, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, sighash
136};
137use bitcoin::consensus::Params;
138use log::{error, info, trace, warn};
139
140use ark::{Vtxo, VtxoId};
141use ark::vtxo::Full;
142use ark::vtxo::policy::signing::VtxoSigner;
143use bitcoin_ext::{BlockHeight, P2TR_DUST};
144
145use crate::Wallet;
146use crate::chain::ChainSource;
147use crate::exit::transaction_manager::ExitTransactionManager;
148use crate::movement::{MovementDestination, MovementStatus, PaymentMethod};
149use crate::movement::manager::MovementManager;
150use crate::movement::update::MovementUpdate;
151use crate::onchain::ExitUnilaterally;
152use crate::persist::BarkPersister;
153use crate::persist::models::StoredExit;
154use crate::psbtext::PsbtInputExt;
155use crate::subsystem::{ExitMovement, Subsystem};
156use crate::vtxo::{VtxoState, VtxoStateKind};
157
158pub struct Exit {
160 tx_manager: ExitTransactionManager,
161 persister: Arc<dyn BarkPersister>,
162 chain_source: Arc<ChainSource>,
163 movement_manager: Arc<MovementManager>,
164
165 exit_vtxos: Vec<ExitVtxo>,
166}
167
168impl Exit {
169 pub (crate) async fn new(
170 persister: Arc<dyn BarkPersister>,
171 chain_source: Arc<ChainSource>,
172 movement_manager: Arc<MovementManager>,
173 ) -> anyhow::Result<Exit> {
174 let tx_manager = ExitTransactionManager::new(persister.clone(), chain_source.clone())?;
175
176 Ok(Exit {
177 exit_vtxos: Vec::new(),
178 tx_manager,
179 persister,
180 chain_source,
181 movement_manager,
182 })
183 }
184
185 pub (crate) async fn load(
186 &mut self,
187 onchain: &dyn ExitUnilaterally,
188 ) -> anyhow::Result<()> {
189 let exit_vtxo_entries = self.persister.get_exit_vtxo_entries().await?;
190 self.exit_vtxos.reserve(exit_vtxo_entries.len());
191
192 for entry in exit_vtxo_entries {
193 if let Some(vtxo) = self.persister.get_wallet_vtxo(entry.vtxo_id).await? {
194 let mut exit = ExitVtxo::from_entry(entry, &vtxo);
195 exit.initialize(&mut self.tx_manager, &*self.persister, onchain).await?;
196 self.exit_vtxos.push(exit);
197 } else {
198 error!("VTXO {} is marked for exit but it's missing from the database", entry.vtxo_id);
199 }
200 }
201 Ok(())
202 }
203
204 pub async fn get_exit_status(
211 &self,
212 vtxo_id: VtxoId,
213 include_history: bool,
214 include_transactions: bool,
215 ) -> Result<Option<ExitTransactionStatus>, ExitError> {
216 match self.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id) {
217 None => Ok(None),
218 Some(exit) => {
219 let mut txs = Vec::new();
220 if include_transactions {
221 if let Some(txids) = exit.txids() {
222 txs.reserve(txids.len());
223 for txid in txids {
224 txs.push(self.tx_manager.get_package(*txid)?.read().await.clone());
225 }
226 } else {
227 let exit_vtxo = exit.get_vtxo(&*self.persister).await?;
228 for tx in exit_vtxo.vtxo.transactions() {
233 txs.push(ExitTransactionPackage {
234 exit: TransactionInfo {
235 txid: tx.tx.compute_txid(),
236 tx: tx.tx,
237 },
238 child: None,
239 })
240 }
241 }
242 }
243 Ok(Some(ExitTransactionStatus {
244 vtxo_id: exit.id(),
245 state: exit.state().clone(),
246 history: if include_history { Some(exit.history().clone()) } else { None },
247 transactions: txs,
248 }))
249 },
250 }
251 }
252
253 pub fn get_exit_vtxo(&self, vtxo_id: VtxoId) -> Option<&ExitVtxo> {
255 self.exit_vtxos.iter().find(|ev| ev.id() == vtxo_id)
256 }
257
258 pub fn get_exit_vtxos(&self) -> &Vec<ExitVtxo> {
260 &self.exit_vtxos
261 }
262
263 pub fn has_pending_exits(&self) -> bool {
265 self.exit_vtxos.iter().any(|ev| ev.state().is_pending())
266 }
267
268 pub fn pending_total(&self) -> Amount {
270 self.exit_vtxos
271 .iter()
272 .filter_map(|ev| {
273 if ev.state().is_pending() {
274 Some(ev.amount())
275 } else {
276 None
277 }
278 }).sum()
279 }
280
281 pub async fn all_claimable_at_height(&self) -> Option<BlockHeight> {
283 let mut highest_claimable_height = None;
284 for exit in &self.exit_vtxos {
285 if matches!(exit.state(), ExitState::Claimed(..)) {
286 continue;
287 }
288 match exit.state().claimable_height() {
289 Some(h) => highest_claimable_height = cmp::max(highest_claimable_height, Some(h)),
290 None => return None,
291 }
292 }
293 highest_claimable_height
294 }
295
296 pub async fn start_exit_for_entire_wallet(&mut self) -> anyhow::Result<()> {
303 let all_vtxos = self.persister.get_vtxos_by_state(&VtxoStateKind::UNSPENT_STATES).await?.into_iter()
304 .map(|v| v.vtxo);
305
306 let (eligible, dust) = all_vtxos.partition::<Vec<_>, _>(|v| v.amount() >= P2TR_DUST);
308
309 for vtxo in &dust {
311 warn!(
312 "Skipping dust VTXO {}: {} sats is below the dust limit ({} sats).",
313 vtxo.id(), vtxo.amount().to_sat(), P2TR_DUST.to_sat()
314 );
315 }
316
317 if eligible.is_empty() && !dust.is_empty() {
319 warn!(
320 "Exit not started: all {} VTXOs (total {}) are below the dust limit. \
321 To exit and consolidate dust, you need to refresh your VTXOs first \
322 (requires total balance >= {})",
323 dust.len(),
324 dust.iter().map(|v| v.amount()).sum::<Amount>(),
325 P2TR_DUST,
326 );
327 return Ok(());
328 }
329
330 self.start_exit_for_vtxos(&eligible).await?;
332
333 Ok(())
334 }
335
336 pub async fn start_exit_for_vtxos<'a>(
343 &mut self,
344 vtxos: &[impl Borrow<Vtxo<Full>>],
345 ) -> anyhow::Result<()> {
346 if vtxos.is_empty() {
347 return Ok(());
348 }
349 let tip = self.chain_source.tip().await?;
350 let params = Params::new(self.chain_source.network());
351 for vtxo in vtxos {
352 let vtxo = vtxo.borrow();
353 let vtxo_id = vtxo.id();
354 if self.exit_vtxos.iter().any(|ev| ev.id() == vtxo_id) {
355 warn!("VTXO {} is already in the exit process", vtxo_id);
356 continue;
357 }
358
359 if vtxo.amount() < P2TR_DUST {
361 return Err(ExitError::DustLimit {
362 vtxo: vtxo.amount(),
363 dust: P2TR_DUST,
364 }.into());
365 }
366
367 trace!("Starting exit for VTXO: {}", vtxo_id);
370 let exit = ExitVtxo::new(vtxo, tip);
371 self.persister.store_exit_vtxo_entry(&StoredExit::new(&exit)).await?;
372 self.persister.update_vtxo_state_checked(
373 vtxo_id, VtxoState::Spent, &VtxoStateKind::UNSPENT_STATES,
374 ).await?;
375 self.exit_vtxos.push(exit);
376 trace!("Exit for VTXO started successfully: {}", vtxo_id);
377
378 let balance = -vtxo.amount().to_signed()?;
380 let script_pubkey = vtxo.output_script_pubkey();
381 let payment_method = match Address::from_script(&script_pubkey, ¶ms) {
382 Ok(addr) => PaymentMethod::Bitcoin(addr.into_unchecked()),
383 Err(e) => {
384 warn!("Unable to convert script pubkey to address: {:#}", e);
385 PaymentMethod::OutputScript(script_pubkey)
386 }
387 };
388
389 self.movement_manager.new_finished_movement(
393 Subsystem::EXIT,
394 ExitMovement::Exit.to_string(),
395 MovementStatus::Successful,
396 MovementUpdate::new()
397 .intended_and_effective_balance(balance)
398 .consumed_vtxo(vtxo_id)
399 .sent_to([MovementDestination::new(payment_method, vtxo.amount())]),
400 ).await.context("Failed to register exit movement")?;
401 }
402 Ok(())
403 }
404
405 pub (crate) async fn dangerous_clear_exit(&mut self) -> anyhow::Result<()> {
409 for exit in &self.exit_vtxos {
410 self.persister.remove_exit_vtxo_entry(&exit.id()).await?;
411 }
412 self.exit_vtxos.clear();
413 Ok(())
414 }
415
416 pub async fn progress_exits(
431 &mut self,
432 wallet: &Wallet,
433 onchain: &mut dyn ExitUnilaterally,
434 fee_rate_override: Option<FeeRate>,
435 ) -> anyhow::Result<Option<Vec<ExitProgressStatus>>> {
436 let mut exit_statuses = Vec::with_capacity(self.exit_vtxos.len());
437 for ev in self.exit_vtxos.iter_mut() {
438 if !ev.is_initialized() {
439 warn!("Skipping progress of uninitialized unilateral exit {}", ev.id());
440 continue;
441 }
442
443 info!("Progressing exit for VTXO {}", ev.id());
444 let error = match ev.progress(
445 wallet,
446 &mut self.tx_manager,
447 onchain,
448 fee_rate_override,
449 true,
450 ).await {
451 Ok(_) => None,
452 Err(e) => {
453 match &e {
454 ExitError::InsufficientConfirmedFunds { .. } => {
455 warn!("Can't progress exit for VTXO {} at this time: {}", ev.id(), e);
456 },
457 _ => {
458 error!("Error progressing exit for VTXO {}: {}", ev.id(), e);
459 }
460 }
461 Some(e)
462 }
463 };
464 if !matches!(ev.state(), ExitState::Claimed(..)) {
465 exit_statuses.push(ExitProgressStatus {
466 vtxo_id: ev.id(),
467 state: ev.state().clone(),
468 error,
469 });
470 }
471 }
472 Ok(Some(exit_statuses))
473 }
474
475 pub async fn sync(
479 &mut self,
480 wallet: &Wallet,
481 onchain: &mut dyn ExitUnilaterally,
482 ) -> anyhow::Result<()> {
483 self.sync_no_progress(onchain).await?;
484 for exit in &mut self.exit_vtxos {
485 if exit.state().requires_network_update() {
487 if let Err(e) = exit.progress(
488 wallet, &mut self.tx_manager, onchain, None, false,
489 ).await {
490 error!("Error syncing exit for VTXO {}: {}", exit.id(), e);
491 }
492 }
493 }
494 Ok(())
495 }
496
497 pub async fn sync_no_progress(
502 &mut self,
503 onchain: &dyn ExitUnilaterally,
504 ) -> anyhow::Result<()> {
505 for exit in &mut self.exit_vtxos {
506 if !exit.is_initialized() {
507 match exit.initialize(&mut self.tx_manager, &*self.persister, onchain).await {
508 Ok(()) => continue,
509 Err(e) => {
510 error!("Error initializing exit for VTXO {}: {:#}", exit.id(), e);
511 }
512 }
513 }
514 }
515 self.tx_manager.sync().await?;
516 Ok(())
517 }
518
519 pub fn list_claimable(&self) -> Vec<&ExitVtxo> {
521 self.exit_vtxos.iter().filter(|ev| ev.is_claimable()).collect()
522 }
523
524 pub async fn sign_exit_claim_inputs(&self, psbt: &mut Psbt, wallet: &Wallet) -> anyhow::Result<()> {
532 let prevouts = psbt.inputs.iter()
533 .map(|i| i.witness_utxo.clone().unwrap())
534 .collect::<Vec<_>>();
535
536 let prevouts = sighash::Prevouts::All(&prevouts);
537 let mut shc = sighash::SighashCache::new(&psbt.unsigned_tx);
538
539 let claimable = self.list_claimable()
540 .into_iter()
541 .map(|e| (e.id(), e))
542 .collect::<HashMap<_, _>>();
543
544 let mut spent = Vec::new();
545 for (i, input) in psbt.inputs.iter_mut().enumerate() {
546 let vtxo = input.get_exit_claim_input();
547
548 if let Some(vtxo) = vtxo {
549 let exit_vtxo = *claimable.get(&vtxo.id()).context("vtxo is not claimable yet")?;
550
551 let witness = wallet.sign_input(&vtxo, i, &mut shc, &prevouts).await
552 .map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
553
554 input.final_script_witness = Some(witness);
555 spent.push(exit_vtxo);
556 }
557 }
558
559 Ok(())
560 }
561
562 pub async fn drain_exits<'a>(
571 &self,
572 inputs: &[impl Borrow<ExitVtxo>],
573 wallet: &Wallet,
574 address: Address,
575 fee_rate_override: Option<FeeRate>,
576 ) -> anyhow::Result<Psbt, ExitError> {
577 let tip = self.chain_source.tip().await
578 .map_err(|e| ExitError::TipRetrievalFailure { error: e.to_string() })?;
579
580 if inputs.is_empty() {
581 return Err(ExitError::ClaimMissingInputs);
582 }
583 let mut vtxos = HashMap::with_capacity(inputs.len());
584 for input in inputs {
585 let i = input.borrow();
586 let vtxo = i.get_vtxo(&*self.persister).await?;
587 vtxos.insert(i.id(), vtxo);
588 }
589
590 let mut tx = {
591 let mut output_amount = Amount::ZERO;
592 let mut tx_ins = Vec::with_capacity(inputs.len());
593 for input in inputs {
594 let input = input.borrow();
595 let vtxo = &vtxos[&input.id()];
596 if !matches!(input.state(), ExitState::Claimable(..)) {
597 return Err(ExitError::VtxoNotClaimable { vtxo: input.id() });
598 }
599
600 output_amount += vtxo.amount();
601
602 let clause = wallet.find_signable_clause(vtxo).await
603 .ok_or(ExitError::ClaimMissingSignableClause { vtxo: vtxo.id() })?;
604
605 tx_ins.push(TxIn {
606 previous_output: vtxo.point(),
607 script_sig: ScriptBuf::default(),
608 sequence: clause.sequence().unwrap_or(Sequence::ZERO),
609 witness: Witness::new(),
610 });
611 }
612
613 let locktime = bitcoin::absolute::LockTime::from_height(tip)
614 .map_err(|e| ExitError::InvalidLocktime { tip, error: e.to_string() })?;
615
616 Transaction {
617 version: bitcoin::transaction::Version(3),
618 lock_time: locktime,
619 input: tx_ins,
620 output: vec![
621 TxOut {
622 script_pubkey: address.script_pubkey(),
623 value: output_amount,
624 },
625 ],
626 }
627 };
628
629 let create_psbt = |tx: Transaction| async {
631 let mut psbt = Psbt::from_unsigned_tx(tx)
632 .map_err(|e| ExitError::InternalError {
633 error: format!("Failed to create exit claim PSBT: {}", e),
634 })?;
635 psbt.inputs.iter_mut().zip(inputs).for_each(|(i, e)| {
636 let v = &vtxos[&e.borrow().id()];
637 i.set_exit_claim_input(&v.vtxo);
638 i.witness_utxo = Some(v.vtxo.txout())
639 });
640 self.sign_exit_claim_inputs(&mut psbt, wallet).await
641 .map_err(|e| ExitError::ClaimSigningError { error: e.to_string() })?;
642 Ok(psbt)
643 };
644 let fee_amount = {
645 let fee_rate = fee_rate_override
646 .unwrap_or(self.chain_source.fee_rates().await.regular);
647 fee_rate * create_psbt(tx.clone()).await?
648 .extract_tx()
649 .map_err(|e| ExitError::InternalError {
650 error: format!("Failed to get tx from signed exit claim PSBT: {}", e),
651 })?
652 .weight()
653 };
654
655 let needed = fee_amount + P2TR_DUST;
657 if needed > tx.output[0].value {
658 return Err(ExitError::ClaimFeeExceedsOutput {
659 needed, output: tx.output[0].value,
660 });
661 }
662 tx.output[0].value -= fee_amount;
663
664 create_psbt(tx).await
666 }
667}