Skip to main content

bark/
board.rs

1use anyhow::Context;
2use bdk_esplora::esplora_client::Amount;
3use bitcoin::key::Keypair;
4use bitcoin::{Address, OutPoint, Psbt};
5use log::{debug, error, info, trace, warn};
6
7use ark::{ProtocolEncoding, VtxoId};
8use ark::board::{BoardBuilder, BOARD_FUNDING_TX_VTXO_VOUT};
9use ark::fees::validate_and_subtract_fee;
10use bitcoin_ext::{BlockHeight, TxStatus};
11use server_rpc::protos;
12
13use crate::{onchain, Wallet, WalletVtxo};
14use crate::movement::MovementStatus;
15use crate::movement::update::MovementUpdate;
16use crate::persist::models::PendingBoard;
17use crate::subsystem::{BoardMovement, Subsystem};
18use crate::vtxo::{VtxoState, VtxoStateKind};
19
20impl Wallet {
21	/// Board a `Vtxo` with the given amount.
22	///
23	/// NB we will spend a little more onchain to cover fees.
24	pub async fn board_amount(
25		&self,
26		onchain: &mut dyn onchain::Board,
27		amount: Amount,
28	) -> anyhow::Result<PendingBoard> {
29		let (user_keypair, _) = self.derive_store_next_keypair().await?;
30		self.board(onchain, Some(amount), user_keypair).await
31	}
32
33	/// Board a `Vtxo` with all the funds in your onchain wallet.
34	pub async fn board_all(
35		&self,
36		onchain: &mut dyn onchain::Board,
37	) -> anyhow::Result<PendingBoard> {
38		let (user_keypair, _) = self.derive_store_next_keypair().await?;
39		self.board(onchain, None, user_keypair).await
40	}
41
42	pub async fn pending_boards(&self) -> anyhow::Result<Vec<PendingBoard>> {
43		let boarding_vtxo_ids = self.inner.db.get_all_pending_board_ids().await?;
44		let mut boards = Vec::with_capacity(boarding_vtxo_ids.len());
45		for vtxo_id in boarding_vtxo_ids {
46			let board = self.inner.db.get_pending_board_by_vtxo_id(vtxo_id).await?
47				.expect("id just retrieved from db");
48			boards.push(board);
49		}
50		Ok(boards)
51	}
52
53	/// Queries the database for any VTXO that is an unregistered board. There is a lag time between
54	/// when a board is created and when it becomes spendable.
55	///
56	/// See [ark::ArkInfo::required_board_confirmations] and [Wallet::sync_pending_boards].
57	pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
58		let vtxo_ids = self.pending_boards().await?.into_iter()
59			.flat_map(|b| b.vtxos.into_iter())
60			.collect::<Vec<_>>();
61
62		let mut vtxos = Vec::with_capacity(vtxo_ids.len());
63		for vtxo_id in vtxo_ids {
64			let vtxo = self.get_vtxo_by_id(vtxo_id).await
65				.expect("vtxo id just got retrieved from db");
66			// We can silently filter out exited VTXOs, next time we sync they will be dropped from
67			// the pending list.
68			match vtxo.state.kind() {
69				VtxoStateKind::Locked => vtxos.push(vtxo),
70				VtxoStateKind::Exited => continue,
71				VtxoStateKind::Spendable | VtxoStateKind::Spent => {
72					warn!("Pending board VTXO {} has unexpected state: {:?}", vtxo_id, vtxo.state);
73					debug_assert!(false, "all pending board vtxos should be locked or exited");
74				}
75			}
76		}
77
78		Ok(vtxos)
79	}
80
81	/// Attempts to register all pendings boards with the Ark server. A board transaction must have
82	/// sufficient confirmations before it will be registered. For more details see
83	/// [ark::ArkInfo::required_board_confirmations].
84	pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
85		let (_, ark_info) = self.require_server().await?;
86		let current_height = self.inner.chain.tip().await?;
87		let unregistered_boards = self.pending_boards().await?;
88		let mut registered_boards = 0;
89
90		if unregistered_boards.is_empty() {
91			return Ok(());
92		}
93
94		trace!("Attempting registration of sufficiently confirmed boards");
95
96		for board in unregistered_boards {
97			let [vtxo_id] = board.vtxos.try_into()
98				.map_err(|_| anyhow!("multiple board vtxos is not supported yet"))?;
99
100			// If we've kicked off an exit and it's progressed beyond the abortable stage,
101			// server-side registration can no longer succeed — the underlying outpoint is
102			// now committed to the exit chain. Drop the pending_board entry so we stop
103			// burning RPC calls on it.
104			let vtxo = self.get_vtxo_by_id(vtxo_id).await?;
105			if vtxo.state.kind() == VtxoStateKind::Exited {
106				debug!("Removing pending_board for exited VTXO {}", vtxo_id);
107				self.inner.db.remove_pending_board(&vtxo_id).await?;
108				self.inner.movements.finish_movement(
109					board.movement_id, MovementStatus::Failed,
110				).await?;
111				continue;
112			}
113
114			let anchor = vtxo.chain_anchor();
115			let confs = match self.inner.chain.tx_status(anchor.txid).await {
116				Ok(TxStatus::Confirmed(block_ref)) => Some(current_height - (block_ref.height - 1)),
117				Ok(TxStatus::Mempool) => Some(0),
118				Ok(TxStatus::NotFound) => None,
119				Err(_) => None,
120			};
121
122			if let Some(confs) = confs {
123				if confs >= ark_info.required_board_confirmations as BlockHeight {
124					if let Err(e) = self.register_board(vtxo.id()).await {
125						warn!("Failed to register board {}: {:#}", vtxo.id(), e);
126					} else {
127						info!("Registered board {}", vtxo.id());
128						registered_boards += 1;
129						continue;
130					}
131				}
132			}
133
134			// Near expiry without registration — kick off an exit so the funds at least
135			// come back onchain, but keep the pending_board entry around so we keep
136			// retrying registration while the exit is still in its abortable
137			// Start/Processing window. If the server becomes available before the exit
138			// commits, `register_board` will succeed and tear down the entry; otherwise
139			// the top-of-loop check above will tear it down once the exit progresses.
140			if vtxo.expiry_height() < current_height + ark_info.required_board_confirmations as BlockHeight {
141				let is_exiting = self.exit_mgr().is_exiting(vtxo.id()).await;
142				if !is_exiting {
143					warn!("VTXO {} expired before its board was confirmed, marking VTXO for exit", vtxo.id());
144					self.inner.exit.start_exit_for_vtxos(&[vtxo.vtxo]).await?;
145					self.inner.movements.update_movement(
146						board.movement_id, MovementUpdate::new().exited_vtxo(vtxo_id),
147					).await?;
148				}
149			}
150		};
151
152		if registered_boards > 0 {
153			info!("Registered {registered_boards} sufficiently confirmed boards");
154		}
155		Ok(())
156	}
157
158	async fn board(
159		&self,
160		wallet: &mut dyn onchain::Board,
161		amount: Option<Amount>,
162		user_keypair: Keypair,
163	) -> anyhow::Result<PendingBoard> {
164		let (addr, expiry_height) = self.board_funding_address(&user_keypair).await?;
165		let fee_rate = self.inner.chain.fee_rates().await.regular;
166
167		let board_psbt = if let Some(amount) = amount {
168			wallet.prepare_tx(&[(addr, amount)], fee_rate)?
169		} else {
170			wallet.prepare_drain_tx(addr, fee_rate)?
171		};
172
173		let signed_psbt = wallet.finish_psbt(board_psbt).await?;
174		self.board_tx(signed_psbt, user_keypair, expiry_height).await
175	}
176
177	/// Returns the funding address for a board with the given keypair.
178	///
179	/// The caller can use this address to build a funding transaction, then pass it
180	/// to [Wallet::board_tx] to complete the board setup.
181	pub async fn board_funding_address(
182		&self,
183		user_keypair: &Keypair,
184	) -> anyhow::Result<(Address, BlockHeight)> {
185		let (_, ark_info) = self.require_server().await?;
186		let properties = self.inner.db.read_properties().await?.context("Missing config")?;
187		let current_height = self.inner.chain.tip().await?;
188
189		let expiry_height = current_height + ark_info.vtxo_expiry_delta as BlockHeight;
190		let builder = BoardBuilder::new(
191			user_keypair.public_key(),
192			expiry_height,
193			ark_info.server_pubkey,
194			ark_info.vtxo_exit_delta,
195		);
196
197		let addr = bitcoin::Address::from_script(
198			&builder.funding_script_pubkey(),
199			properties.network,
200		)?;
201
202		Ok((addr, expiry_height))
203	}
204
205	/// Board a [Vtxo] using a signed funding PSBT.
206	///
207	/// The PSBT must be signed and send funds to the address returned by
208	/// [Wallet::board_funding_address] at output index [BOARD_FUNDING_TX_VTXO_VOUT].
209	pub async fn board_tx(
210		&self,
211		board_psbt: Psbt,
212		user_keypair: Keypair,
213		expiry_height: BlockHeight,
214	) -> anyhow::Result<PendingBoard> {
215		let (mut srv, ark_info) = self.require_server().await?;
216
217		let builder = BoardBuilder::new(
218			user_keypair.public_key(),
219			expiry_height,
220			ark_info.server_pubkey,
221			ark_info.vtxo_exit_delta,
222		);
223
224		let board_output = board_psbt.unsigned_tx.output.get(BOARD_FUNDING_TX_VTXO_VOUT as usize)
225			.context("PSBT does not have output at board funding vout index")?;
226		let expected_script = builder.funding_script_pubkey();
227		ensure!(
228			board_output.script_pubkey == expected_script,
229			"PSBT output does not pay to the expected board funding address",
230		);
231
232		let amount = board_output.value;
233		ensure!(amount >= ark_info.min_board_amount,
234			"board amount of {amount} is less than minimum board amount required by server ({})",
235			ark_info.min_board_amount,
236		);
237		let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
238		validate_and_subtract_fee(amount, fee)?;
239
240		let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
241		let builder = builder
242			.set_funding_details(amount, fee, utxo)
243			.context("error setting funding details for board")?
244			.generate_user_nonces();
245
246		let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
247			amount: amount.to_sat(),
248			utxo: bitcoin::consensus::serialize(&utxo), //TODO(stevenroose) change to own
249			expiry_height,
250			user_pubkey: user_keypair.public_key().serialize().to_vec(),
251			pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
252		}).await.context("error requesting board cosign")?
253			.into_inner().try_into().context("invalid cosign response from server")?;
254
255		ensure!(builder.verify_cosign_response(&cosign_resp),
256				"invalid board cosignature received from server",
257			);
258
259		// Store vtxo first before we actually make the on-chain tx.
260		let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
261
262		let onchain_fee = board_psbt.fee()?;
263		let movement_id = self.inner.movements.new_movement_with_update(
264			Subsystem::BOARD,
265			BoardMovement::Board.to_string(),
266			MovementUpdate::new()
267				.intended_balance(amount.to_signed()?)
268				.effective_balance(vtxo.amount().to_signed()?)
269				.fee(fee)
270				.produced_vtxo(&vtxo)
271				.metadata(BoardMovement::metadata(utxo, onchain_fee)),
272		).await?;
273		self.store_locked_vtxos(
274			[&vtxo],
275			Some(crate::vtxo::VtxoLockHolder::Movement { id: movement_id }),
276		).await?;
277
278		let tx = board_psbt.extract_tx()?;
279		self.inner.db.store_pending_board(&vtxo, &tx, movement_id).await?;
280
281		trace!("Broadcasting board tx: {}", bitcoin::consensus::encode::serialize_hex(&tx));
282		self.inner.chain.broadcast_tx(&tx).await?;
283
284		info!("Board broadcasted");
285		Ok(self.inner.db.get_pending_board_by_vtxo_id(vtxo.id()).await?.expect("board should be stored"))
286	}
287
288	/// Registers a board to the Ark server
289	async fn register_board(&self, vtxo_id: VtxoId) -> anyhow::Result<()> {
290		trace!("Attempting to register board {} to server", vtxo_id);
291		let (mut srv, _) = self.require_server().await?;
292
293		// Get the full vtxo (including the genesis chain) since we send the
294		// serialized bytes to the server.
295		let vtxo = self.inner.db.get_full_vtxo(vtxo_id).await?
296			.with_context(|| format!("VTXO doesn't exist: {}", vtxo_id))?;
297
298		// Register the vtxo with the server
299		srv.client.register_board_vtxo(protos::BoardVtxoRequest {
300			board_vtxo: vtxo.serialize(),
301		}).await.context("error registering board with the Ark server")?;
302
303		// Remember that we have stored the vtxo
304		// No need to complain if the vtxo is already registered
305		self.inner.db.update_vtxo_state_checked(
306			vtxo.id(), VtxoState::Spendable, &VtxoStateKind::UNSPENT_STATES,
307		).await?;
308
309		// Post vtxo ID to server for recovery (non-critical, just log errors)
310		if let Err(e) = self.post_recovery_vtxo_ids([vtxo.id()]).await {
311			error!("Failed to post recovery vtxo ID to server: {:#}", e);
312		}
313
314		let board = self.inner.db.get_pending_board_by_vtxo_id(vtxo.id()).await?
315			.context("pending board not found")?;
316
317		// TODO(pc): Cancel any pending exits for the VTXO once we support doing so.
318		self.inner.movements.finish_movement(board.movement_id, MovementStatus::Successful).await?;
319		self.inner.db.remove_pending_board(&vtxo.id()).await?;
320
321		Ok(())
322	}
323}