1use anyhow::Context;
14use bitcoin::{Amount, OutPoint, SignedAmount, Transaction};
15use log::{error, info, warn};
16
17use ark::{ProtocolEncoding, Vtxo};
18use ark::board::BOARD_FUNDING_TX_VTXO_VOUT;
19use ark::vtxo::{Full, VtxoId};
20use bitcoin_ext::{BlockHeight, TxStatus};
21use server_rpc::protos;
22
23use crate::Wallet;
24use crate::actions::{Advance, AdvanceError, WalletAction, WalletActionId};
25use crate::chain::BroadcastError;
26use crate::movement::{MovementId, MovementStatus};
27use crate::movement::update::MovementUpdate;
28use crate::vtxo::{VtxoState, VtxoStateKind};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Board {
34 pub id: WalletActionId,
36 #[serde(with = "bitcoin_ext::serde::encodable")]
39 pub funding_tx: Transaction,
40 pub vtxo_id: VtxoId,
43 #[serde(with = "bitcoin::amount::serde::as_sat")]
44 pub amount: Amount,
45 pub movement_id: MovementId,
47
48 pub progress: Progress,
50}
51
52impl Board {
53 pub fn id(&self) -> WalletActionId {
54 self.id.clone()
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub enum Progress {
61 Broadcasting {
65 #[serde(with = "ark::encode::serde")]
66 signed_vtxo: Vtxo<Full>,
67 },
68 Confirming {
74 last_park_error: Option<String>,
76 },
77}
78
79pub(crate) fn board_action_id(utxo: OutPoint) -> WalletActionId {
85 format!("board.{}.{}", utxo.txid, utxo.vout)
86}
87
88#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
89#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
90impl WalletAction for Board {
91 fn id(&self) -> WalletActionId { Board::id(self) }
92
93 async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError> {
94 match self.progress.clone() {
95 Progress::Broadcasting { signed_vtxo } => {
96 run_broadcast(wallet, &self, signed_vtxo).await?;
97 Ok(Advance::Next(Board {
98 progress: Progress::Confirming { last_park_error: None },
99 ..self
100 }))
101 },
102 Progress::Confirming { .. } => run_confirm(wallet, self).await,
103 }
104 }
105
106 async fn on_rejection(
107 self,
108 _wallet: &Wallet,
109 error: AdvanceError,
110 ) -> anyhow::Result<Advance<Self>> {
111 warn!("board {} hit an unexpected rejection, re-evaluating: {:#}", self.id, error);
117 let progress = match self.progress {
119 broadcasting @ Progress::Broadcasting { .. } => broadcasting,
120 Progress::Confirming { .. } => Progress::Confirming { last_park_error: None },
121 };
122 Ok(Advance::Park {
123 state: Board { progress, ..self },
124 wake_after: None,
125 error: None,
126 })
127 }
128}
129
130async fn run_broadcast(
135 wallet: &Wallet,
136 board: &Board,
137 signed_vtxo: Vtxo<Full>,
138) -> Result<(), AdvanceError> {
139 wallet.store_vtxos(
143 [&signed_vtxo],
144 &VtxoState::Locked {
145 holder: Some(crate::vtxo::VtxoLockHolder::Movement { id: board.movement_id }),
146 },
147 ).await?;
148
149 let utxo = OutPoint::new(board.funding_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
150 let already_known = matches!(
155 wallet.inner.chain.tx_status(utxo.txid).await,
156 Ok(TxStatus::Mempool) | Ok(TxStatus::Confirmed(_)),
157 );
158 if !already_known {
159 wallet.inner.chain.broadcast_tx(&board.funding_tx).await?;
160 info!("Board {} funding tx broadcasted", board.id);
161 }
162 Ok(())
163}
164
165async fn run_confirm(wallet: &Wallet, board: Board) -> Result<Advance<Board>, AdvanceError> {
171 let (_, ark_info) = wallet.require_server().await?;
172 let current_height = wallet.inner.chain.tip().await?;
173 let required = ark_info.required_board_confirmations as BlockHeight;
174
175 let vtxo = wallet.get_vtxo_by_id(board.vtxo_id).await?;
176
177 if vtxo.state.kind() == VtxoStateKind::Exited {
180 wallet.inner.movements.finish_movement(board.movement_id, MovementStatus::Failed).await
181 .context("failed to finalize exited board movement")?;
182 return Ok(Advance::Done);
183 }
184
185 if vtxo.state.kind() == VtxoStateKind::Spent {
189 wallet.inner.movements.finish_movement_with_update(
190 board.movement_id, MovementStatus::Failed,
191 MovementUpdate::new().effective_balance(SignedAmount::ZERO),
192 ).await.context("failed to finalize double-spent board movement")?;
193 return Ok(Advance::Done);
194 }
195
196 let mut last_park_error = None;
197 let anchor = vtxo.chain_anchor();
198 let confs = match wallet.inner.chain.tx_status(anchor.txid).await {
199 Ok(TxStatus::Confirmed(block_ref)) =>
200 Some(current_height.saturating_sub(block_ref.height).saturating_add(1)),
201 Ok(TxStatus::Mempool) => Some(0),
202 Ok(TxStatus::NotFound) => {
207 match funding_conflict(wallet, &board).await? {
208 FundingConflict::Fatal => {
209 warn!("Board {} funding input was spent by a confirmed \
210 conflicting tx, failing the board", board.id);
211 wallet.inner.db.update_vtxo_state_checked(
212 board.vtxo_id, VtxoState::Spent, &[VtxoStateKind::Locked],
213 ).await.context("failed to mark double-spent board vtxo as spent")?;
214 wallet.inner.movements.finish_movement_with_update(
215 board.movement_id, MovementStatus::Failed,
216 MovementUpdate::new().effective_balance(SignedAmount::ZERO),
217 ).await.context("failed to finalize double-spent board movement")?;
218 return Ok(Advance::Done);
219 },
220 FundingConflict::Undecided(reason) => {
223 return Ok(Advance::Park {
224 state: Board {
225 progress: Progress::Confirming {
226 last_park_error: Some(reason),
227 },
228 ..board
229 },
230 wake_after: None,
231 error: None,
232 });
233 },
234 FundingConflict::None => Some(0),
238 }
239 },
240 Err(_) => None,
241 };
242
243 if confs.is_some_and(|c| c >= required) {
244 match run_register(wallet, &board).await {
249 Ok(()) => return Ok(Advance::Done),
250 Err(e) => {
251 let reason = format!("{:#}", e);
252 warn!("Failed to register board {}: {}", board.id, reason);
253 last_park_error = Some(reason);
254 },
255 }
256 }
257
258 if vtxo.expiry_height() < current_height.saturating_add(required) {
265 if !wallet.exit_mgr().is_exiting(vtxo.id()).await {
266 warn!("Board {} expired before confirmation, marking VTXO for exit", board.id);
267 wallet.inner.exit.start_exit_for_vtxos(&[vtxo.vtxo.clone()]).await?;
268 }
269 wallet.inner.movements.update_movement(
272 board.movement_id, MovementUpdate::new().exited_vtxo(board.vtxo_id),
273 ).await.context("failed to record board exit on movement")?;
274 }
275
276 Ok(Advance::Park {
277 state: Board { progress: Progress::Confirming { last_park_error }, ..board },
278 wake_after: None,
279 error: None,
280 })
281}
282
283async fn run_register(wallet: &Wallet, board: &Board) -> anyhow::Result<()> {
287 let (mut srv, _) = wallet.require_server().await?;
288
289 let vtxo = wallet.get_full_vtxo(board.vtxo_id).await
292 .with_context(|| format!("board vtxo doesn't exist: {}", board.vtxo_id))?;
293
294 srv.client.register_board_vtxo(protos::BoardVtxoRequest {
295 board_vtxo: vtxo.serialize(),
296 }).await.context("error registering board with the Ark server")?;
297
298 wallet.inner.db.update_vtxo_state_checked(
299 vtxo.id(), crate::vtxo::VtxoState::Spendable, VtxoStateKind::UNSPENT_STATES,
300 ).await?;
301
302 if let Err(e) = wallet.post_recovery_vtxo_ids([vtxo.id()]).await {
307 error!("Failed to post recovery vtxo ID to server: {:#}", e);
308 }
309
310 wallet.inner.movements.finish_movement(board.movement_id, MovementStatus::Successful).await
312 .context("failed to finalize board movement")?;
313
314 info!("Registered board {}", vtxo.id());
315 Ok(())
316}
317
318enum FundingConflict {
320 None,
323 Undecided(String),
328 Fatal,
331}
332
333async fn funding_conflict(wallet: &Wallet, board: &Board) -> anyhow::Result<FundingConflict> {
346 for input in &board.funding_tx.input {
347 let parent = input.previous_output.txid;
348 match wallet.inner.chain.tx_status(parent).await? {
349 TxStatus::Confirmed(_) | TxStatus::Mempool => {},
350 TxStatus::NotFound => return Ok(FundingConflict::Undecided(format!(
351 "funding input parent tx {} not yet visible on chain", parent,
352 ))),
353 }
354 }
355
356 match wallet.inner.chain.broadcast_package(std::slice::from_ref(&board.funding_tx)).await {
357 Ok(()) | Err(BroadcastError::AlreadyKnown) => Ok(FundingConflict::None),
358 Err(BroadcastError::MissingOrSpentInputs) => Ok(FundingConflict::Fatal),
359 Err(e) => Ok(FundingConflict::Undecided(
360 format!("funding tx re-broadcast rejected: {}", e),
361 )),
362 }
363}