use anyhow::Context;
use bitcoin::{Amount, OutPoint, Psbt, SignedAmount, Transaction, Txid};
use log::{error, info, warn};
use ark::{ProtocolEncoding, Vtxo};
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};
pub(crate) fn psbt_is_finalized(psbt: &Psbt) -> bool {
psbt.inputs.iter().all(|i| i.final_script_sig.is_some() || i.final_script_witness.is_some())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Board {
pub id: WalletActionId,
#[serde(default, skip_serializing_if = "Option::is_none",
with = "bitcoin_ext::serde::encodable::opt")]
pub funding_tx: Option<Transaction>,
#[serde(default, skip_serializing_if = "Option::is_none",
with = "bitcoin_ext::serde::psbt::opt")]
pub funding_psbt: Option<Psbt>,
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()
}
pub fn funding(&self) -> anyhow::Result<&Transaction> {
self.funding_tx.as_ref()
.or(self.funding_psbt.as_ref().map(|psbt| &psbt.unsigned_tx))
.context("board checkpoint has no funding transaction")
}
pub fn funding_txid(&self) -> anyhow::Result<Txid> {
Ok(self.funding()?.compute_txid())
}
pub fn to_broadcast(&self) -> anyhow::Result<Option<Transaction>> {
if let Some(tx) = &self.funding_tx {
return Ok(Some(tx.clone()));
}
let psbt = self.funding_psbt.as_ref()
.context("board checkpoint has no funding transaction")?;
if !psbt_is_finalized(psbt) {
return Ok(None);
}
Ok(Some(psbt.clone().extract_tx().context("failed to extract board funding tx")?))
}
}
#[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 already_known = matches!(
wallet.inner.chain.tx_status(board.funding_txid()?).await,
Ok(TxStatus::Mempool) | Ok(TxStatus::Confirmed(_)),
);
match board.to_broadcast()? {
None => {
if already_known {
info!("Board {} funding tx is not ours to broadcast, awaiting it", board.id)
}
},
Some(tx) => {
if !already_known {
wallet.inner.chain.broadcast_tx(&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()?.input {
let parent = input.previous_output.txid;
match wallet.inner.chain.tx_status(parent).await? {
TxStatus::Confirmed(_) => {
if wallet.inner.chain.outpoint_spent_confirmed(input.previous_output).await? {
return Ok(FundingConflict::Fatal);
}
},
TxStatus::Mempool => {},
TxStatus::NotFound => return Ok(FundingConflict::Undecided(format!(
"funding input parent tx {} not yet visible on chain", parent,
))),
}
}
let Some(tx) = board.to_broadcast()? else {
return Ok(FundingConflict::Undecided("awaiting external funding broadcast".into()));
};
match wallet.inner.chain.broadcast_package(std::slice::from_ref(&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),
)),
}
}
#[cfg(test)]
mod test {
use bitcoin::{ScriptBuf, Sequence, TxIn, TxOut, Witness, consensus};
use bitcoin::locktime::absolute::LockTime;
use bitcoin::transaction::Version;
use super::*;
fn unsigned_tx() -> Transaction {
Transaction {
version: Version::TWO,
lock_time: LockTime::ZERO,
input: vec![TxIn {
previous_output: OutPoint::null(),
script_sig: ScriptBuf::new(),
sequence: Sequence::MAX,
witness: Witness::new(),
}],
output: vec![TxOut {
value: Amount::from_sat(1_000_000),
script_pubkey: ScriptBuf::new_op_return(&[0u8; 4]),
}],
}
}
fn psbt() -> Psbt {
let mut psbt = Psbt::from_unsigned_tx(unsigned_tx()).unwrap();
psbt.inputs[0].witness_utxo = Some(TxOut {
value: Amount::from_sat(1_001_000),
script_pubkey: ScriptBuf::new_op_return(&[1u8; 4]),
});
psbt
}
fn finalized_psbt() -> Psbt {
let mut psbt = psbt();
psbt.inputs[0].final_script_witness = Some(Witness::from_slice(&[[0u8; 64]]));
psbt
}
fn board(funding_tx: Option<Transaction>, funding_psbt: Option<Psbt>) -> Board {
Board {
id: "board.test.0".to_string(),
funding_tx,
funding_psbt,
vtxo_id: VtxoId::from(OutPoint::null()),
amount: Amount::from_sat(1_000_000),
movement_id: MovementId(7),
progress: Progress::Confirming { last_park_error: None },
}
}
#[test]
fn broadcast_follows_psbt_finalisation() {
let unfinalized = board(None, Some(psbt()));
assert!(unfinalized.to_broadcast().unwrap().is_none());
assert_eq!(unfinalized.funding_txid().unwrap(), psbt().unsigned_tx.compute_txid());
let finalized = board(None, Some(finalized_psbt()));
let tx = finalized.to_broadcast().unwrap().expect("finalised proposal is ours to send");
assert_eq!(tx.compute_txid(), finalized.funding_txid().unwrap());
assert!(!tx.input[0].witness.is_empty(), "extracted tx must carry the witness");
}
#[test]
fn board_without_funding_is_an_error() {
let board = board(None, None);
assert!(board.funding().is_err());
assert!(board.funding_txid().is_err());
assert!(board.to_broadcast().is_err());
}
#[test]
fn legacy_funding_tx_is_read_and_broadcastable() {
let tx = {
let mut tx = unsigned_tx();
tx.input[0].witness = Witness::from_slice(&[[0u8; 64]]);
tx
};
let legacy = serde_json::json!({
"id": "board.test.0",
"funding_tx": consensus::encode::serialize_hex(&tx),
"vtxo_id": VtxoId::from(OutPoint::null()),
"amount": 1_000_000,
"movement_id": 7,
"progress": { "Confirming": { "last_park_error": null } },
});
let board: Board = serde_json::from_value(legacy).unwrap();
assert_eq!(board.funding_tx.as_ref(), Some(&tx));
assert_eq!(board.funding_psbt, None);
assert_eq!(board.to_broadcast().unwrap(), Some(tx));
}
#[test]
fn checkpoint_writes_only_funding_psbt() {
let psbt = psbt();
let json = serde_json::to_value(board(None, Some(psbt.clone()))).unwrap();
assert_eq!(json["funding_psbt"], psbt.serialize_hex());
assert!(json.get("funding_tx").is_none(), "funding_tx is never written: {json}");
let board: Board = serde_json::from_value(json).unwrap();
assert_eq!(board.funding_psbt, Some(psbt));
assert_eq!(board.funding_tx, None);
}
#[test]
fn both_checkpoint_versions_deserialise() {
fn payload(funding_key: &str, funding_hex: String) -> serde_json::Value {
serde_json::json!({
"id": "board.test.0",
funding_key: funding_hex,
"vtxo_id": VtxoId::from(OutPoint::null()),
"amount": 1_000_000,
"movement_id": 7,
"progress": { "Confirming": { "last_park_error": null } },
})
}
let tx = finalized_psbt().extract_tx().unwrap();
let v1: Board = serde_json::from_value(
payload("funding_tx", consensus::encode::serialize_hex(&tx)),
).expect("a funding_tx checkpoint must still deserialise");
assert_eq!(v1.funding_tx, Some(tx.clone()));
assert_eq!(v1.funding_psbt, None);
let psbt = psbt();
let v2: Board = serde_json::from_value(
payload("funding_psbt", psbt.serialize_hex()),
).expect("a funding_psbt checkpoint must deserialise");
assert_eq!(v2.funding_psbt, Some(psbt));
assert_eq!(v2.funding_tx, None);
assert_eq!(v1.funding_txid().unwrap(), v2.funding_txid().unwrap());
}
}