Skip to main content

bark/actions/
board.rs

1//! Board wallet action.
2//!
3//! Boarding moves on-chain BTC into the Ark/VTXO world. Server cosign and
4//! vtxo construction need the user keypair and the on-chain wallet, neither of
5//! which is reachable from [`WalletAction::advance`], so those happen
6//! synchronously in [`crate::Wallet::board_tx`]. This action takes over at the
7//! first point funds become committed (broadcast) and owns the durable part of
8//! the lifecycle: broadcast -> confirm -> register, plus the near-expiry exit
9//! salvage path. Identity (`id`, `funding_tx`, `vtxo_id`, `amount`,
10//! `movement_id`) lives on [`Board`] as top-level fields; the mutable bit is the
11//! [`Progress`] enum.
12
13use 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/// An in-flight board, persisted as a single checkpoint row and driven across
31/// crashes by the executor.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Board {
34	// Immutable state:
35	pub id: WalletActionId,
36	/// The signed funding transaction. Carried so (re-)broadcast is re-drivable
37	/// without the on-chain wallet, which isn't available inside `advance`.
38	#[serde(with = "bitcoin_ext::serde::encodable")]
39	pub funding_tx: Transaction,
40	/// The board vtxo produced by the cosign, built before this checkpoint
41	/// exists. The full vtxo is reloaded from the db when needed.
42	pub vtxo_id: VtxoId,
43	#[serde(with = "bitcoin::amount::serde::as_sat")]
44	pub amount: Amount,
45	/// Created up front in `board_tx` so re-driving never duplicates a movement.
46	pub movement_id: MovementId,
47
48	// Mutable state:
49	pub progress: Progress,
50}
51
52impl Board {
53	pub fn id(&self) -> WalletActionId {
54		self.id.clone()
55	}
56}
57
58/// The phases of an in-flight board.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub enum Progress {
61	/// Vtxo cosigned and built but not yet persisted. Store it (locked under the
62	/// action id) and broadcast the funding tx. Carries the signed vtxo because
63	/// it isn't in the vtxo table until this step stores it.
64	Broadcasting {
65		#[serde(with = "ark::encode::serde")]
66		signed_vtxo: Vtxo<Full>,
67	},
68	/// Funding tx broadcast. Each pass waits for `required_board_confirmations`,
69	/// registers with the server once confirmed, and kicks off an exit if the
70	/// vtxo nears expiry unregistered (salvage). This mirrors the pre-action
71	/// `sync_pending_boards` loop body so registration keeps being retried (with
72	/// the vtxo left Locked) until it succeeds, the board exits, or it expires.
73	Confirming {
74		/// Most recent reason a registration attempt failed, for diagnostics.
75		last_park_error: Option<String>,
76	},
77}
78
79/// Stable action id derived from the funding outpoint. Known before broadcast
80/// and unique because a given funding output can only board once.
81///
82/// Uses `.` rather than the `txid:vout` colon since action ids double as lock
83/// keys, which only permit ASCII alphanumerics, `-`, `_` and `.`.
84pub(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		// The funding tx is already on-chain by the time the action runs, so we
112		// can never safely fail-and-release. Registration failures are handled
113		// inline in `Confirming` (kept retrying, vtxo left Locked), so a
114		// rejection reaching here is unexpected; re-evaluate from `Confirming`
115		// rather than tearing the board down.
116		warn!("board {} hit an unexpected rejection, re-evaluating: {:#}", self.id, error);
117		// Keep `Broadcasting` so its `signed_vtxo` survives for the next drive.
118		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
130/// `Broadcasting -> Confirming`. Store the cosigned vtxo locked under the action
131/// and broadcast the funding tx. Both steps are idempotent: `store_locked_vtxos`
132/// no-ops if the vtxo exists, and we skip the broadcast if the tx is already
133/// known to the chain.
134async fn run_broadcast(
135	wallet: &Wallet,
136	board: &Board,
137	signed_vtxo: Vtxo<Full>,
138) -> Result<(), AdvanceError> {
139	// The server doesn't know this vtxo until `register_board`, so skip the
140	// recovery-mailbox post `store_locked_vtxos` would do (it would fail the
141	// mailbox FK to `vtxo`); `register_board` posts it once accepted.
142	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	// Skip the broadcast only on a positive "already on-chain" signal. A
151	// not-yet-broadcast funding tx is unknown to the chain source, and some
152	// backends report that by erroring rather than returning `NotFound`, so
153	// treat anything but a confirmed/mempool hit as "still needs broadcasting".
154	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
165/// `Confirming`. Mirrors the pre-action `sync_pending_boards` loop body, run
166/// once per drive: tear down if the vtxo has exited, re-broadcast if the funding
167/// tx dropped (or fail the board if it was double-spent), register once
168/// sufficiently confirmed, and kick off an exit near expiry (keeping the board
169/// around so registration can still win while the exit is abortable).
170async 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 an exit has progressed beyond the abortable stage, server-side
178	// registration can no longer succeed: finish the movement and stop.
179	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	// A previous drive already observed a confirmed double-spend of a funding
186	// tx input and marked the vtxo spent (see the `Fatal` arm below), but was
187	// interrupted before tearing the board down: finish the teardown.
188	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		// Dropped from the mempool before confirming. Probe for a conflicting
203		// spend of the funding inputs: if one has confirmed the funding tx can
204		// never confirm, so the board is dead and re-broadcasting would strand
205		// it in a park-and-retry loop forever.
206		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				// The funding tx may still confirm: park and re-check next
221				// drive.
222				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				// Nothing conflicts: the probe already put the funding tx back
235				// in the mempool, so a single eviction doesn't strand the board
236				// forever (the old flow never did).
237				FundingConflict::None => Some(0),
238			}
239		},
240		Err(_) => None,
241	};
242
243	if confs.is_some_and(|c| c >= required) {
244		// Attempt registration inline. A failure here (the server can't see
245		// enough confirmations yet, or refuses) is not terminal: leave the vtxo
246		// Locked and retry next drive, exactly like the old loop. The funding tx
247		// is already on-chain, so we must never fail-and-release.
248		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	// Near expiry without registration: kick off an exit so the funds at least
259	// come back on-chain, but keep retrying registration while the exit is still
260	// abortable. The top-of-function `Exited` check tears the action down once
261	// the exit commits.
262	//
263	// I know this if is collapsible, but it reads better like this...
264	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		// Record unconditionally (idempotent): a crash after `start_exit_for_vtxos`
270		// would otherwise leave `is_exiting` true and never record the exit.
271		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
283/// Register the board with the server, mark the vtxo spendable and finalize the
284/// movement. All steps are idempotent: the server tolerates an already-registered
285/// board and the state update is gated on the unspent states.
286async fn run_register(wallet: &Wallet, board: &Board) -> anyhow::Result<()> {
287	let (mut srv, _) = wallet.require_server().await?;
288
289	// Get the full vtxo (including the genesis chain) since we send the
290	// serialized bytes to the server.
291	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	// Post vtxo ID for recovery (non-critical, just log errors). Done here
303	// rather than in `store_locked_vtxos` because the server only has the
304	// vtxo row after `register_board_vtxo` above, so the mailbox FK would
305	// otherwise fail.
306	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	// TODO(pc): Cancel any pending exits for the VTXO once we support doing so.
311	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
318/// How the board funding tx fares after being dropped from the mempool.
319enum FundingConflict {
320	/// Nothing conflicts: the re-broadcast probe put the funding tx back into
321	/// the mempool.
322	None,
323	/// The outcome is still open (a parent tx isn't visible yet, a competing
324	/// unconfirmed spend is in the way, or the node rejected the re-broadcast
325	/// transiently), so the funding tx may still confirm. Carries the park
326	/// reason.
327	Undecided(String),
328	/// A funding input was spent by a confirmed conflicting tx, so the funding
329	/// tx can never confirm: the board is dead.
330	Fatal,
331}
332
333/// Classify the board funding tx after it dropped out of the mempool, without
334/// scanning the chain (a full block scan from the vtxo's creation height is
335/// prohibitively slow against Bitcoin Core).
336///
337/// First confirm every funding input's parent tx is visible on-chain or in the
338/// mempool. A missing parent is not fatal: an evicted ancestor can re-enter the
339/// mempool once a cluster/package limit clears, so we park and wait. Once all
340/// parents are present, re-broadcasting the funding tx reveals whether its
341/// inputs are still spendable: a `missing or spent inputs` rejection then means
342/// a confirmed conflict consumed one of them and the board is dead. Any other
343/// rejection (a competing unconfirmed spend, an RBF fee shortfall, or a
344/// transient node error) leaves the outcome open, so we park.
345async 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}