mod error;
mod package;
mod states;
pub use self::package::{
ChildTransactionInfo, ExitCpfpRequest, ExitTransactionPackage, FeeInfo, RbfRequirement,
TransactionInfo,
};
pub use self::error::ExitError;
pub use self::states::{
ExitTx, ExitTxStatus, ExitTxOrigin, ExitStartState, ExitProcessingState, ExitAwaitingDeltaState,
ExitClaimableState, ExitClaimInProgressState, ExitClaimedState, ExitVtxoAlreadySpentState,
ExitCanceledState,
};
use std::fmt;
use ark::VtxoId;
use bitcoin::Txid;
use bitcoin_ext::{BlockDelta, BlockHeight, BlockRef, TxStatus};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ExitState {
Start(ExitStartState),
Processing(ExitProcessingState),
AwaitingDelta(ExitAwaitingDeltaState),
Claimable(ExitClaimableState),
ClaimInProgress(ExitClaimInProgressState),
Claimed(ExitClaimedState),
VtxoAlreadySpent(ExitVtxoAlreadySpentState),
Canceled(ExitCanceledState),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExitStateKind {
Start,
Processing,
AwaitingDelta,
Claimable,
ClaimInProgress,
Claimed,
VtxoAlreadySpent,
Canceled,
}
impl ExitStateKind {
pub const ALL: &[ExitStateKind] = &[
ExitStateKind::Start,
ExitStateKind::Processing,
ExitStateKind::AwaitingDelta,
ExitStateKind::Claimable,
ExitStateKind::ClaimInProgress,
ExitStateKind::Claimed,
ExitStateKind::VtxoAlreadySpent,
ExitStateKind::Canceled,
];
pub const LIVE_STATES: &[ExitStateKind] = &[
ExitStateKind::Start,
ExitStateKind::Processing,
ExitStateKind::AwaitingDelta,
ExitStateKind::Claimable,
ExitStateKind::ClaimInProgress,
];
pub const FINISHED_STATES: &[ExitStateKind] = &[
ExitStateKind::Claimed,
ExitStateKind::VtxoAlreadySpent,
ExitStateKind::Canceled,
];
pub fn as_str(&self) -> &'static str {
match self {
ExitStateKind::Start => "start",
ExitStateKind::Processing => "processing",
ExitStateKind::AwaitingDelta => "awaiting-delta",
ExitStateKind::Claimable => "claimable",
ExitStateKind::ClaimInProgress => "claim-in-progress",
ExitStateKind::Claimed => "claimed",
ExitStateKind::VtxoAlreadySpent => "vtxo-already-spent",
ExitStateKind::Canceled => "canceled",
}
}
}
impl fmt::Display for ExitStateKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl ExitState {
pub fn kind(&self) -> ExitStateKind {
match self {
ExitState::Start(_) => ExitStateKind::Start,
ExitState::Processing(_) => ExitStateKind::Processing,
ExitState::AwaitingDelta(_) => ExitStateKind::AwaitingDelta,
ExitState::Claimable(_) => ExitStateKind::Claimable,
ExitState::ClaimInProgress(_) => ExitStateKind::ClaimInProgress,
ExitState::Claimed(_) => ExitStateKind::Claimed,
ExitState::VtxoAlreadySpent(_) => ExitStateKind::VtxoAlreadySpent,
ExitState::Canceled(_) => ExitStateKind::Canceled,
}
}
pub fn new_start(tip: BlockHeight) -> Self {
ExitState::Start(ExitStartState { tip_height: tip })
}
pub fn new_processing<T: IntoIterator<Item = Txid>>(tip: BlockHeight, txids: T) -> Self {
ExitState::Processing(ExitProcessingState {
tip_height: tip,
transactions: txids.into_iter()
.map(|id| ExitTx {
txid: id,
status: ExitTxStatus::VerifyInputs,
})
.collect::<Vec<_>>(),
})
}
pub fn new_processing_from_transactions(tip: BlockHeight, transactions: Vec<ExitTx>) -> Self {
ExitState::Processing(ExitProcessingState {
tip_height: tip,
transactions,
})
}
pub fn new_awaiting_delta(
tip: BlockHeight,
confirmed_block: BlockRef,
wait_delta: BlockDelta
) -> Self {
debug_assert_ne!(wait_delta, 0, "wait delta must be non-zero");
let claimable_height = confirmed_block.height + wait_delta as BlockHeight;
ExitState::AwaitingDelta(ExitAwaitingDeltaState {
tip_height: tip,
confirmed_block,
claimable_height,
})
}
pub fn new_claimable(
tip: BlockHeight,
claimable_since: BlockRef,
last_scanned_block: Option<BlockRef>
) -> Self {
ExitState::Claimable(ExitClaimableState {
tip_height: tip,
claimable_since,
last_scanned_block,
})
}
pub fn new_claim_in_progress(
tip: BlockHeight,
claimable_since: BlockRef,
claim_txid: Txid
) -> Self {
ExitState::ClaimInProgress(ExitClaimInProgressState {
tip_height: tip,
claimable_since,
claim_txid,
})
}
pub fn new_claimed(tip: BlockHeight, txid: Txid, block: BlockRef) -> Self {
ExitState::Claimed(ExitClaimedState {
tip_height: tip,
txid,
block,
})
}
pub fn new_vtxo_already_spent(tip: BlockHeight) -> Self {
ExitState::VtxoAlreadySpent(ExitVtxoAlreadySpentState { tip_height: tip })
}
pub fn new_canceled(tip: BlockHeight) -> Self {
ExitState::Canceled(ExitCanceledState { tip_height: tip })
}
pub fn is_pending(&self) -> bool {
match self {
ExitState::Start(_) => true,
ExitState::Processing(_) => true,
ExitState::AwaitingDelta(_) => true,
_ => false,
}
}
pub fn is_claimable(&self) -> bool {
match self {
ExitState::Claimable(_) => true,
_ => false,
}
}
pub fn is_cancelable(&self) -> bool {
match self {
ExitState::Start(_) => true,
ExitState::Processing(s) => s.transactions.last().map_or(true, |tx| matches!(
tx.status,
ExitTxStatus::VerifyInputs
| ExitTxStatus::AwaitingInputConfirmation { .. }
| ExitTxStatus::AwaitingCpfpBroadcast,
)),
_ => false,
}
}
pub fn requires_confirmations(&self) -> bool {
match self {
ExitState::Processing(s) => {
s.transactions.iter().any(|s| match s.status {
ExitTxStatus::AwaitingInputConfirmation { .. } => true,
ExitTxStatus::AwaitingConfirmation { .. } => true,
_ => false,
})
},
ExitState::AwaitingDelta(_) => true,
ExitState::ClaimInProgress(_) => true,
_ => false,
}
}
pub fn claimable_height(&self) -> Option<BlockHeight> {
match self {
ExitState::AwaitingDelta(s) => Some(s.claimable_height),
ExitState::Claimable(s) => Some(s.claimable_since.height),
ExitState::ClaimInProgress(s) => Some(s.claimable_since.height),
_ => None,
}
}
pub fn warrants_exited_vtxo(&self) -> bool {
match self {
ExitState::Start(_) => false,
ExitState::Processing(_) => false,
ExitState::AwaitingDelta(_) => true,
ExitState::Claimable(_) => true,
ExitState::ClaimInProgress(_) => true,
ExitState::Claimed(_) => true,
ExitState::VtxoAlreadySpent(_) => false,
ExitState::Canceled(_) => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitProgressStatus {
pub vtxo_id: VtxoId,
pub state: ExitState,
pub error: Option<ExitError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExitTransactionStatus {
pub vtxo_id: VtxoId,
pub state: ExitState,
pub history: Option<Vec<ExitState>>,
pub transactions: Vec<ExitTransactionPackage>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ExitChildStatus {
pub txid: Txid,
pub status: TxStatus,
pub origin: ExitTxOrigin,
pub fee_info: Option<FeeInfo>,
}
#[cfg(test)]
mod test {
use super::*;
use bitcoin::hashes::Hash;
fn txid(n: u8) -> Txid {
Txid::from_byte_array([n; 32])
}
fn tx(n: u8, status: ExitTxStatus) -> ExitTx {
ExitTx { txid: txid(n), status }
}
fn broadcast() -> ExitTxStatus {
ExitTxStatus::AwaitingConfirmation { child_txid: txid(99), origin: ExitTxOrigin::Mempool }
}
fn all_states() -> [ExitState; 8] {
let block_ref = BlockRef { height: 1, hash: bitcoin::BlockHash::all_zeros() };
[
ExitState::new_start(1),
ExitState::new_processing(1, [txid(1)]),
ExitState::new_awaiting_delta(1, block_ref, 10),
ExitState::new_claimable(1, block_ref, Some(block_ref)),
ExitState::new_claim_in_progress(1, block_ref, txid(1)),
ExitState::new_claimed(1, txid(1), block_ref),
ExitState::new_vtxo_already_spent(1),
ExitState::new_canceled(1),
]
}
#[test]
fn is_cancelable_only_checks_the_final_tx() {
assert!(ExitState::new_start(100).is_cancelable());
assert!(ExitState::new_processing_from_transactions(100, vec![
tx(1, ExitTxStatus::VerifyInputs),
tx(2, ExitTxStatus::AwaitingCpfpBroadcast),
]).is_cancelable());
assert!(ExitState::new_processing_from_transactions(100, vec![
tx(1, broadcast()),
tx(2, ExitTxStatus::AwaitingCpfpBroadcast),
]).is_cancelable());
assert!(!ExitState::new_processing_from_transactions(100, vec![
tx(1, broadcast()),
tx(2, broadcast()),
]).is_cancelable());
assert!(!ExitState::new_canceled(100).is_cancelable());
assert!(!ExitState::new_vtxo_already_spent(100).is_cancelable());
}
#[test]
fn exit_state_kind_tag_matches_serde() {
for state in all_states() {
let state_tag = serde_json::to_value(&state).unwrap()
.get("type").unwrap().as_str().unwrap().to_string();
let kind_tag = serde_json::to_value(state.kind()).unwrap()
.as_str().unwrap().to_string();
assert_eq!(state_tag, kind_tag, "tag mismatch for {:?}", state.kind());
}
for &kind in ExitStateKind::ALL {
let serde_tag = serde_json::to_value(kind).unwrap().as_str().unwrap().to_string();
assert_eq!(kind.as_str(), serde_tag, "as_str mismatch for {:?}", kind);
}
}
#[test]
fn exit_state_kind_all_is_exhaustive() {
match ExitStateKind::Start {
ExitStateKind::Start => {},
ExitStateKind::Processing => {},
ExitStateKind::AwaitingDelta => {},
ExitStateKind::Claimable => {},
ExitStateKind::ClaimInProgress => {},
ExitStateKind::Claimed => {},
ExitStateKind::VtxoAlreadySpent => {},
ExitStateKind::Canceled => {},
}
assert_eq!(ExitStateKind::ALL.len(), all_states().len());
for state in all_states() {
let count = ExitStateKind::ALL.iter().filter(|&&k| k == state.kind()).count();
assert_eq!(count, 1, "{:?} should appear exactly once in ALL", state.kind());
}
}
#[test]
fn exit_state_kind_live_and_finished_states_partition_all() {
for &kind in ExitStateKind::ALL {
let live = !matches!(
kind,
ExitStateKind::Claimed
| ExitStateKind::VtxoAlreadySpent
| ExitStateKind::Canceled,
);
assert_eq!(
ExitStateKind::LIVE_STATES.contains(&kind), live,
"LIVE_STATES membership wrong for {:?}", kind,
);
assert_eq!(
ExitStateKind::FINISHED_STATES.contains(&kind), !live,
"FINISHED_STATES membership wrong for {:?}", kind,
);
}
for state in all_states() {
if state.is_pending() {
assert!(
ExitStateKind::LIVE_STATES.contains(&state.kind()),
"{:?} is_pending() but its kind is not in LIVE_STATES", state.kind(),
);
}
}
}
}