bark/
board.rs

1use anyhow::Context;
2use bdk_esplora::esplora_client::Amount;
3use bitcoin::key::Keypair;
4use bitcoin::OutPoint;
5use log::{info, trace, warn};
6
7use ark::ProtocolEncoding;
8use ark::board::{BoardBuilder, BOARD_FUNDING_TX_VTXO_VOUT};
9use ark::fees::validate_and_subtract_fee;
10use ark::vtxo::VtxoRef;
11use bitcoin_ext::{BlockHeight, TxStatus};
12use server_rpc::protos;
13
14use crate::{onchain, Wallet, WalletVtxo};
15use crate::movement::MovementStatus;
16use crate::movement::update::MovementUpdate;
17use crate::persist::models::PendingBoard;
18use crate::subsystem::{BoardMovement, Subsystem};
19use crate::vtxo::{VtxoState, VtxoStateKind};
20
21impl Wallet {
22	/// Board a [Vtxo] with the given amount.
23	///
24	/// NB we will spend a little more onchain to cover fees.
25	pub async fn board_amount(
26		&self,
27		onchain: &mut dyn onchain::Board,
28		amount: Amount,
29	) -> anyhow::Result<PendingBoard> {
30		let (user_keypair, _) = self.derive_store_next_keypair().await?;
31		self.board(onchain, Some(amount), user_keypair).await
32	}
33
34	/// Board a [Vtxo] with all the funds in your onchain wallet.
35	pub async fn board_all(
36		&self,
37		onchain: &mut dyn onchain::Board,
38	) -> anyhow::Result<PendingBoard> {
39		let (user_keypair, _) = self.derive_store_next_keypair().await?;
40		self.board(onchain, None, user_keypair).await
41	}
42
43	pub async fn pending_boards(&self) -> anyhow::Result<Vec<PendingBoard>> {
44		let boarding_vtxo_ids = self.db.get_all_pending_board_ids().await?;
45		let mut boards = Vec::with_capacity(boarding_vtxo_ids.len());
46		for vtxo_id in boarding_vtxo_ids {
47			let board = self.db.get_pending_board_by_vtxo_id(vtxo_id).await?
48				.expect("id just retrieved from db");
49			boards.push(board);
50		}
51		Ok(boards)
52	}
53
54	/// Queries the database for any VTXO that is an unregistered board. There is a lag time between
55	/// when a board is created and when it becomes spendable.
56	///
57	/// See [ArkInfo::required_board_confirmations] and [Wallet::sync_pending_boards].
58	pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
59		let vtxo_ids = self.pending_boards().await?.into_iter()
60			.flat_map(|b| b.vtxos.into_iter())
61			.collect::<Vec<_>>();
62
63		let mut vtxos = Vec::with_capacity(vtxo_ids.len());
64		for vtxo_id in vtxo_ids {
65			let vtxo = self.get_vtxo_by_id(vtxo_id).await
66				.expect("vtxo id just got retrieved from db");
67			vtxos.push(vtxo);
68		}
69
70		debug_assert!(vtxos.iter().all(|v| matches!(v.state.kind(), VtxoStateKind::Locked)),
71			"all pending board vtxos should be locked"
72		);
73
74		Ok(vtxos)
75	}
76
77	/// Attempts to register all pendings boards with the Ark server. A board transaction must have
78	/// sufficient confirmations before it will be registered. For more details see
79	/// [ArkInfo::required_board_confirmations].
80	pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
81		let (_, ark_info) = self.require_server().await?;
82		let current_height = self.chain.tip().await?;
83		let unregistered_boards = self.pending_boards().await?;
84		let mut registered_boards = 0;
85
86		if unregistered_boards.is_empty() {
87			return Ok(());
88		}
89
90		trace!("Attempting registration of sufficiently confirmed boards");
91
92		for board in unregistered_boards {
93			let [vtxo_id] = board.vtxos.try_into()
94				.map_err(|_| anyhow!("multiple board vtxos is not supported yet"))?;
95
96			let vtxo = self.get_vtxo_by_id(vtxo_id).await?;
97
98			let anchor = vtxo.chain_anchor();
99			let confs = match self.chain.tx_status(anchor.txid).await {
100				Ok(TxStatus::Confirmed(block_ref)) => Some(current_height - (block_ref.height - 1)),
101				Ok(TxStatus::Mempool) => Some(0),
102				Ok(TxStatus::NotFound) => None,
103				Err(_) => None,
104			};
105
106			if let Some(confs) = confs {
107				if confs >= ark_info.required_board_confirmations as BlockHeight {
108					if let Err(e) = self.register_board(vtxo.id()).await {
109						warn!("Failed to register board {}: {:#}", vtxo.id(), e);
110					} else {
111						info!("Registered board {}", vtxo.id());
112						registered_boards += 1;
113						continue;
114					}
115				}
116			}
117
118			if vtxo.expiry_height() < current_height + ark_info.required_board_confirmations as BlockHeight {
119				warn!("VTXO {} expired before its board was confirmed, removing board and marking VTXO for exit", vtxo.id());
120				self.exit.write().await.start_exit_for_vtxos(&[vtxo.vtxo]).await?;
121				self.movements.finish_movement_with_update(
122					board.movement_id,
123					MovementStatus::Failed,
124					MovementUpdate::new()
125						.exited_vtxo(vtxo_id),
126				).await?;
127
128				self.db.remove_pending_board(&vtxo_id).await?;
129			}
130		};
131
132		if registered_boards > 0 {
133			info!("Registered {registered_boards} sufficiently confirmed boards");
134		}
135		Ok(())
136	}
137
138	async fn board(
139		&self,
140		wallet: &mut dyn onchain::Board,
141		amount: Option<Amount>,
142		user_keypair: Keypair,
143	) -> anyhow::Result<PendingBoard> {
144		let (mut srv, ark_info) = self.require_server().await?;
145
146		let properties = self.db.read_properties().await?.context("Missing config")?;
147		let current_height = self.chain.tip().await?;
148
149		let expiry_height = current_height + ark_info.vtxo_expiry_delta as BlockHeight;
150		let builder = BoardBuilder::new(
151			user_keypair.public_key(),
152			expiry_height,
153			ark_info.server_pubkey,
154			ark_info.vtxo_exit_delta,
155		);
156
157		let addr = bitcoin::Address::from_script(
158			&builder.funding_script_pubkey(),
159			properties.network,
160		)?;
161
162		// We create the board tx template, but don't sign it yet.
163		let fee_rate = self.chain.fee_rates().await.regular;
164		let (board_psbt, amount) = if let Some(amount) = amount {
165			let psbt = wallet.prepare_tx(&[(addr, amount)], fee_rate)?;
166			(psbt, amount)
167		} else {
168			let psbt = wallet.prepare_drain_tx(addr, fee_rate)?;
169			assert_eq!(psbt.unsigned_tx.output.len(), 1);
170			let amount = psbt.unsigned_tx.output[0].value;
171			(psbt, amount)
172		};
173		ensure!(amount >= ark_info.min_board_amount,
174			"board amount of {amount} is less than minimum board amount required by server ({})",
175			ark_info.min_board_amount,
176		);
177		let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
178		validate_and_subtract_fee(amount, fee)?;
179
180		let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
181		let builder = builder
182			.set_funding_details(amount, fee, utxo)
183			.context("error setting funding details for board")?
184			.generate_user_nonces();
185
186		let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
187			amount: amount.to_sat(),
188			utxo: bitcoin::consensus::serialize(&utxo), //TODO(stevenroose) change to own
189			expiry_height,
190			user_pubkey: user_keypair.public_key().serialize().to_vec(),
191			pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
192		}).await.context("error requesting board cosign")?
193			.into_inner().try_into().context("invalid cosign response from server")?;
194
195		ensure!(builder.verify_cosign_response(&cosign_resp),
196				"invalid board cosignature received from server",
197			);
198
199		// Store vtxo first before we actually make the on-chain tx.
200		let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
201
202		let onchain_fee = board_psbt.fee()?;
203		let movement_id = self.movements.new_movement_with_update(
204			Subsystem::BOARD,
205			BoardMovement::Board.to_string(),
206			MovementUpdate::new()
207				.intended_balance(amount.to_signed()?)
208				.effective_balance(vtxo.amount().to_signed()?)
209				.fee(fee)
210				.produced_vtxo(&vtxo)
211				.metadata(BoardMovement::metadata(utxo, onchain_fee)),
212		).await?;
213		self.store_locked_vtxos([&vtxo], Some(movement_id)).await?;
214
215		let tx = wallet.finish_tx(board_psbt).await?;
216		self.db.store_pending_board(&vtxo, &tx, movement_id).await?;
217
218		trace!("Broadcasting board tx: {}", bitcoin::consensus::encode::serialize_hex(&tx));
219		self.chain.broadcast_tx(&tx).await?;
220
221		info!("Board broadcasted");
222		Ok(self.db.get_pending_board_by_vtxo_id(vtxo.id()).await?.expect("board should be stored"))
223	}
224
225	/// Registers a board to the Ark server
226	async fn register_board(&self, vtxo: impl VtxoRef) -> anyhow::Result<()> {
227		trace!("Attempting to register board {} to server", vtxo.vtxo_id());
228		let (mut srv, _) = self.require_server().await?;
229
230		// Get the vtxo and funding transaction from the database
231		let wallet_vtxo;
232		let vtxo = match vtxo.as_full_vtxo() {
233			Some(v) => v,
234			None => {
235				wallet_vtxo = self.db.get_wallet_vtxo(vtxo.vtxo_id()).await?
236					.with_context(|| format!("VTXO doesn't exist: {}", vtxo.vtxo_id()))?;
237				&wallet_vtxo.vtxo
238			},
239		};
240
241		// Register the vtxo with the server
242		srv.client.register_board_vtxo(protos::BoardVtxoRequest {
243			board_vtxo: vtxo.serialize(),
244		}).await.context("error registering board with the Ark server")?;
245
246		// Remember that we have stored the vtxo
247		// No need to complain if the vtxo is already registered
248		self.db.update_vtxo_state_checked(
249			vtxo.id(), VtxoState::Spendable, &VtxoStateKind::UNSPENT_STATES,
250		).await?;
251
252		let board = self.db.get_pending_board_by_vtxo_id(vtxo.id()).await?
253			.context("pending board not found")?;
254
255		self.movements.finish_movement(board.movement_id, MovementStatus::Successful).await?;
256		self.db.remove_pending_board(&vtxo.id()).await?;
257
258		Ok(())
259	}
260}