use anyhow::Context;
use bdk_esplora::esplora_client::Amount;
use bitcoin::key::Keypair;
use bitcoin::{Address, OutPoint, Psbt};
use log::{info, warn};
use ark::board::{BoardBuilder, BOARD_FUNDING_TX_VTXO_VOUT};
use ark::fees::validate_and_subtract_fee;
use bitcoin_ext::BlockHeight;
use server_rpc::{protos, MAX_NB_BOARD_FUNDING_INPUTS};
use crate::{Wallet, WalletVtxo};
use crate::actions::DriveMode;
use crate::actions::board::{Board, Progress, board_action_id};
use crate::movement::update::MovementUpdate;
use crate::persist::models::PendingBoard;
use crate::subsystem::{BoardMovement, Subsystem};
use crate::vtxo::VtxoStateKind;
impl Wallet {
pub async fn board_amount(&self, amount: Amount) -> anyhow::Result<PendingBoard> {
let (user_keypair, _) = self.derive_store_next_keypair().await?;
self.board(Some(amount), user_keypair).await
}
pub async fn board_all(&self) -> anyhow::Result<PendingBoard> {
let (user_keypair, _) = self.derive_store_next_keypair().await?;
self.board(None, user_keypair).await
}
pub async fn pending_boards(&self) -> anyhow::Result<Vec<PendingBoard>> {
Ok(self.boards_in_progress().await?
.into_iter()
.map(|b| PendingBoard {
funding_tx: b.funding_tx,
vtxos: vec![b.vtxo_id],
amount: b.amount,
movement_id: b.movement_id,
})
.collect())
}
pub(crate) async fn boards_in_progress(&self) -> anyhow::Result<Vec<Board>> {
Ok(self.inner.db.get_all_wallet_action_checkpoints().await?
.into_iter()
.filter_map(|cp| cp.into_board())
.collect())
}
pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
let boards = self.boards_in_progress().await?;
let mut vtxos = Vec::with_capacity(boards.len());
for board in boards {
let vtxo_id = board.vtxo_id;
let vtxo = match self.get_vtxo_by_id(vtxo_id).await {
Ok(vtxo) => vtxo,
Err(e) => match board.progress {
Progress::Broadcasting { .. } => continue,
Progress::Confirming { .. } => return Err(e),
},
};
match vtxo.state.kind() {
VtxoStateKind::Locked => vtxos.push(vtxo),
VtxoStateKind::Exited | VtxoStateKind::Spent => continue,
VtxoStateKind::Spendable => {
warn!("Pending board VTXO {} has unexpected state: {:?}", vtxo_id, vtxo.state);
debug_assert!(false, "all pending board vtxos should be locked, spent or exited");
}
}
}
Ok(vtxos)
}
pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
let pending = self.boards_in_progress().await?;
if pending.is_empty() {
return Ok(());
}
info!("Syncing {} pending boards", pending.len());
for board in pending {
let id = board.id();
if let Err(e) = self.drive_action(board, DriveMode::UntilParkOrDone).await {
warn!("Failed to sync board {}: {:#}", id, e);
}
}
Ok(())
}
async fn board(
&self,
amount: Option<Amount>,
user_keypair: Keypair,
) -> anyhow::Result<PendingBoard> {
let onchain = self.inner.onchain.as_ref()
.ok_or_else(|| anyhow!("no onchain wallet configured; cannot board"))?;
let (addr, expiry_height) = self.board_funding_address(&user_keypair).await?;
let fee_rate = self.inner.chain.fee_rates().await.regular;
let signed_psbt = {
let mut wallet = onchain.write().await;
let board_psbt = if let Some(amount) = amount {
wallet.prepare_tx(&[(addr, amount)], fee_rate).await?
} else {
wallet.prepare_drain_tx(addr, fee_rate).await?
};
if board_psbt.inputs.len() > MAX_NB_BOARD_FUNDING_INPUTS {
bail!("We need {} inputs to board, exceeding the limit of {}",
board_psbt.inputs.len(), MAX_NB_BOARD_FUNDING_INPUTS,
);
}
wallet.finish_psbt(board_psbt).await?
};
self.board_tx(signed_psbt, user_keypair, expiry_height).await
}
pub async fn board_funding_address(
&self,
user_keypair: &Keypair,
) -> anyhow::Result<(Address, BlockHeight)> {
let (_, ark_info) = self.require_server().await?;
let properties = self.inner.db.read_properties().await?.context("Missing config")?;
let current_height = self.inner.chain.tip().await?;
let expiry_height = current_height + ark_info.vtxo_expiry_delta as BlockHeight;
let builder = BoardBuilder::new(
user_keypair.public_key(),
expiry_height,
ark_info.server_pubkey,
ark_info.vtxo_exit_delta,
);
let addr = bitcoin::Address::from_script(
&builder.funding_script_pubkey(),
properties.network,
)?;
Ok((addr, expiry_height))
}
pub async fn board_tx(
&self,
board_psbt: Psbt,
user_keypair: Keypair,
expiry_height: BlockHeight,
) -> anyhow::Result<PendingBoard> {
let (mut srv, ark_info) = self.require_server().await?;
let builder = BoardBuilder::new(
user_keypair.public_key(),
expiry_height,
ark_info.server_pubkey,
ark_info.vtxo_exit_delta,
);
let board_output = board_psbt.unsigned_tx.output.get(BOARD_FUNDING_TX_VTXO_VOUT as usize)
.context("PSBT does not have output at board funding vout index")?;
let expected_script = builder.funding_script_pubkey();
ensure!(
board_output.script_pubkey == expected_script,
"PSBT output does not pay to the expected board funding address",
);
let amount = board_output.value;
ensure!(amount >= ark_info.min_board_amount,
"board amount of {amount} is less than minimum board amount required by server ({})",
ark_info.min_board_amount,
);
let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
validate_and_subtract_fee(amount, fee)?;
let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
let builder = builder
.set_funding_details(amount, fee, utxo)
.context("error setting funding details for board")?
.generate_user_nonces();
let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
amount: amount.to_sat(),
utxo: bitcoin::consensus::serialize(&utxo), expiry_height,
user_pubkey: user_keypair.public_key().serialize().to_vec(),
pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
funding_tx: bitcoin::consensus::serialize(&board_psbt.unsigned_tx),
}).await.context("error requesting board cosign")?
.into_inner().try_into().context("invalid cosign response from server")?;
ensure!(builder.verify_cosign_response(&cosign_resp),
"invalid board cosignature received from server",
);
let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
let onchain_fee = board_psbt.fee()?;
let movement_id = self.inner.movements.new_movement_with_update(
Subsystem::BOARD,
BoardMovement::Board.to_string(),
MovementUpdate::new()
.intended_balance(amount.to_signed()?)
.effective_balance(vtxo.amount().to_signed()?)
.fee(fee)
.produced_vtxo(&vtxo)
.metadata(BoardMovement::metadata(utxo, onchain_fee)),
).await?;
let tx = board_psbt.extract_tx()?;
let vtxo_id = vtxo.id();
let vtxo_amount = vtxo.amount();
let board = Board {
id: board_action_id(utxo),
funding_tx: tx,
vtxo_id,
amount: vtxo_amount,
movement_id,
progress: Progress::Broadcasting { signed_vtxo: vtxo },
};
self.inner.db.upsert_wallet_action_checkpoint(&board.id, &board.clone().into()).await?;
let pending = PendingBoard {
funding_tx: board.funding_tx.clone(),
vtxos: vec![vtxo_id],
amount: vtxo_amount,
movement_id,
};
match self.drive_action(board, DriveMode::UntilParkOrDone).await {
Ok(()) => info!("Board broadcasted"),
Err(e) => warn!("Initial board drive failed, sync will retry: {:#}", e),
}
Ok(pending)
}
}