use anyhow::Context;
use bitcoin::{Amount, OutPoint, SignedAmount, Transaction};
use log::{error, info, warn};
use ark::{ProtocolEncoding, Vtxo};
use ark::board::BOARD_FUNDING_TX_VTXO_VOUT;
use ark::vtxo::{Full, VtxoId};
use bitcoin_ext::{BlockHeight, TxStatus};
use server_rpc::protos;
use crate::Wallet;
use crate::actions::{Advance, AdvanceError, WalletAction, WalletActionId};
use crate::chain::BroadcastError;
use crate::movement::{MovementId, MovementStatus};
use crate::movement::update::MovementUpdate;
use crate::vtxo::{VtxoState, VtxoStateKind};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Board {
pub id: WalletActionId,
#[serde(with = "bitcoin_ext::serde::encodable")]
pub funding_tx: Transaction,
pub vtxo_id: VtxoId,
#[serde(with = "bitcoin::amount::serde::as_sat")]
pub amount: Amount,
pub movement_id: MovementId,
pub progress: Progress,
}
impl Board {
pub fn id(&self) -> WalletActionId {
self.id.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Progress {
Broadcasting {
#[serde(with = "ark::encode::serde")]
signed_vtxo: Vtxo<Full>,
},
Confirming {
last_park_error: Option<String>,
},
}
pub(crate) fn board_action_id(utxo: OutPoint) -> WalletActionId {
format!("board.{}.{}", utxo.txid, utxo.vout)
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl WalletAction for Board {
fn id(&self) -> WalletActionId { Board::id(self) }
async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError> {
match self.progress.clone() {
Progress::Broadcasting { signed_vtxo } => {
run_broadcast(wallet, &self, signed_vtxo).await?;
Ok(Advance::Next(Board {
progress: Progress::Confirming { last_park_error: None },
..self
}))
},
Progress::Confirming { .. } => run_confirm(wallet, self).await,
}
}
async fn on_rejection(
self,
_wallet: &Wallet,
error: AdvanceError,
) -> anyhow::Result<Advance<Self>> {
warn!("board {} hit an unexpected rejection, re-evaluating: {:#}", self.id, error);
let progress = match self.progress {
broadcasting @ Progress::Broadcasting { .. } => broadcasting,
Progress::Confirming { .. } => Progress::Confirming { last_park_error: None },
};
Ok(Advance::Park {
state: Board { progress, ..self },
wake_after: None,
error: None,
})
}
}
async fn run_broadcast(
wallet: &Wallet,
board: &Board,
signed_vtxo: Vtxo<Full>,
) -> Result<(), AdvanceError> {
wallet.store_vtxos(
[&signed_vtxo],
&VtxoState::Locked {
holder: Some(crate::vtxo::VtxoLockHolder::Movement { id: board.movement_id }),
},
).await?;
let utxo = OutPoint::new(board.funding_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
let already_known = matches!(
wallet.inner.chain.tx_status(utxo.txid).await,
Ok(TxStatus::Mempool) | Ok(TxStatus::Confirmed(_)),
);
if !already_known {
wallet.inner.chain.broadcast_tx(&board.funding_tx).await?;
info!("Board {} funding tx broadcasted", board.id);
}
Ok(())
}
async fn run_confirm(wallet: &Wallet, board: Board) -> Result<Advance<Board>, AdvanceError> {
let (_, ark_info) = wallet.require_server().await?;
let current_height = wallet.inner.chain.tip().await?;
let required = ark_info.required_board_confirmations as BlockHeight;
let vtxo = wallet.get_vtxo_by_id(board.vtxo_id).await?;
if vtxo.state.kind() == VtxoStateKind::Exited {
wallet.inner.movements.finish_movement(board.movement_id, MovementStatus::Failed).await
.context("failed to finalize exited board movement")?;
return Ok(Advance::Done);
}
if vtxo.state.kind() == VtxoStateKind::Spent {
wallet.inner.movements.finish_movement_with_update(
board.movement_id, MovementStatus::Failed,
MovementUpdate::new().effective_balance(SignedAmount::ZERO),
).await.context("failed to finalize double-spent board movement")?;
return Ok(Advance::Done);
}
let mut last_park_error = None;
let anchor = vtxo.chain_anchor();
let confs = match wallet.inner.chain.tx_status(anchor.txid).await {
Ok(TxStatus::Confirmed(block_ref)) =>
Some(current_height.saturating_sub(block_ref.height).saturating_add(1)),
Ok(TxStatus::Mempool) => Some(0),
Ok(TxStatus::NotFound) => {
match funding_conflict(wallet, &board).await? {
FundingConflict::Fatal => {
warn!("Board {} funding input was spent by a confirmed \
conflicting tx, failing the board", board.id);
wallet.inner.db.update_vtxo_state_checked(
board.vtxo_id, VtxoState::Spent, &[VtxoStateKind::Locked],
).await.context("failed to mark double-spent board vtxo as spent")?;
wallet.inner.movements.finish_movement_with_update(
board.movement_id, MovementStatus::Failed,
MovementUpdate::new().effective_balance(SignedAmount::ZERO),
).await.context("failed to finalize double-spent board movement")?;
return Ok(Advance::Done);
},
FundingConflict::Undecided(reason) => {
return Ok(Advance::Park {
state: Board {
progress: Progress::Confirming {
last_park_error: Some(reason),
},
..board
},
wake_after: None,
error: None,
});
},
FundingConflict::None => Some(0),
}
},
Err(_) => None,
};
if confs.is_some_and(|c| c >= required) {
match run_register(wallet, &board).await {
Ok(()) => return Ok(Advance::Done),
Err(e) => {
let reason = format!("{:#}", e);
warn!("Failed to register board {}: {}", board.id, reason);
last_park_error = Some(reason);
},
}
}
if vtxo.expiry_height() < current_height.saturating_add(required) {
if !wallet.exit_mgr().is_exiting(vtxo.id()).await {
warn!("Board {} expired before confirmation, marking VTXO for exit", board.id);
wallet.inner.exit.start_exit_for_vtxos(&[vtxo.vtxo.clone()]).await?;
}
wallet.inner.movements.update_movement(
board.movement_id, MovementUpdate::new().exited_vtxo(board.vtxo_id),
).await.context("failed to record board exit on movement")?;
}
Ok(Advance::Park {
state: Board { progress: Progress::Confirming { last_park_error }, ..board },
wake_after: None,
error: None,
})
}
async fn run_register(wallet: &Wallet, board: &Board) -> anyhow::Result<()> {
let (mut srv, _) = wallet.require_server().await?;
let vtxo = wallet.get_full_vtxo(board.vtxo_id).await
.with_context(|| format!("board vtxo doesn't exist: {}", board.vtxo_id))?;
srv.client.register_board_vtxo(protos::BoardVtxoRequest {
board_vtxo: vtxo.serialize(),
}).await.context("error registering board with the Ark server")?;
wallet.inner.db.update_vtxo_state_checked(
vtxo.id(), crate::vtxo::VtxoState::Spendable, VtxoStateKind::UNSPENT_STATES,
).await?;
if let Err(e) = wallet.post_recovery_vtxo_ids([vtxo.id()]).await {
error!("Failed to post recovery vtxo ID to server: {:#}", e);
}
wallet.inner.movements.finish_movement(board.movement_id, MovementStatus::Successful).await
.context("failed to finalize board movement")?;
info!("Registered board {}", vtxo.id());
Ok(())
}
enum FundingConflict {
None,
Undecided(String),
Fatal,
}
async fn funding_conflict(wallet: &Wallet, board: &Board) -> anyhow::Result<FundingConflict> {
for input in &board.funding_tx.input {
let parent = input.previous_output.txid;
match wallet.inner.chain.tx_status(parent).await? {
TxStatus::Confirmed(_) | TxStatus::Mempool => {},
TxStatus::NotFound => return Ok(FundingConflict::Undecided(format!(
"funding input parent tx {} not yet visible on chain", parent,
))),
}
}
match wallet.inner.chain.broadcast_package(std::slice::from_ref(&board.funding_tx)).await {
Ok(()) | Err(BroadcastError::AlreadyKnown) => Ok(FundingConflict::None),
Err(BroadcastError::MissingOrSpentInputs) => Ok(FundingConflict::Fatal),
Err(e) => Ok(FundingConflict::Undecided(
format!("funding tx re-broadcast rejected: {}", e),
)),
}
}