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::{info, warn};
6
7use ark::board::{BoardBuilder, BOARD_FUNDING_TX_VTXO_VOUT};
8use ark::fees::validate_and_subtract_fee;
9use bitcoin_ext::BlockHeight;
10use server_rpc::{protos, MAX_NB_BOARD_FUNDING_INPUTS};
11
12use crate::{Wallet, WalletVtxo};
13use crate::actions::DriveMode;
14use crate::actions::board::{Board, Progress, board_action_id};
15use crate::movement::update::MovementUpdate;
16use crate::persist::models::PendingBoard;
17use crate::subsystem::{BoardMovement, Subsystem};
18use crate::vtxo::VtxoStateKind;
19
20impl Wallet {
21	/// Board a [ark::Vtxo] with the given amount.
22	///
23	/// NB we will spend a little more onchain to cover fees.
24	///
25	/// Returns an error if no onchain wallet is configured.
26	pub async fn board_amount(&self, amount: Amount) -> anyhow::Result<PendingBoard> {
27		let (user_keypair, _) = self.derive_store_next_keypair().await?;
28		self.board(Some(amount), user_keypair).await
29	}
30
31	/// Board a [ark::Vtxo] with all the funds in your onchain wallet.
32	///
33	/// Returns an error if no onchain wallet is configured.
34	pub async fn board_all(&self) -> anyhow::Result<PendingBoard> {
35		let (user_keypair, _) = self.derive_store_next_keypair().await?;
36		self.board(None, user_keypair).await
37	}
38
39	pub async fn pending_boards(&self) -> anyhow::Result<Vec<PendingBoard>> {
40		Ok(self.boards_in_progress().await?
41			.into_iter()
42			.map(|b| PendingBoard {
43				funding_tx: b.funding_tx,
44				vtxos: vec![b.vtxo_id],
45				amount: b.amount,
46				movement_id: b.movement_id,
47			})
48			.collect())
49	}
50
51	/// Returns every in-progress board checkpoint.
52	pub(crate) async fn boards_in_progress(&self) -> anyhow::Result<Vec<Board>> {
53		Ok(self.inner.db.get_all_wallet_action_checkpoints().await?
54			.into_iter()
55			.filter_map(|cp| cp.into_board())
56			.collect())
57	}
58
59	/// Queries the database for any VTXO that is an unregistered board. There is a lag time between
60	/// when a board is created and when it becomes spendable.
61	///
62	/// See [ark::ArkInfo::required_board_confirmations] and [Wallet::sync_pending_boards].
63	pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
64		let boards = self.boards_in_progress().await?;
65
66		let mut vtxos = Vec::with_capacity(boards.len());
67		for board in boards {
68			let vtxo_id = board.vtxo_id;
69			let vtxo = match self.get_vtxo_by_id(vtxo_id).await {
70				Ok(vtxo) => vtxo,
71				// `Broadcasting` hasn't stored its vtxo yet, so skip it; later
72				// states must have one, so a lookup error is real and propagates.
73				Err(e) => match board.progress {
74					Progress::Broadcasting { .. } => continue,
75					Progress::Confirming { .. } => return Err(e),
76				},
77			};
78			// We can silently filter out exited VTXOs, next time we sync they will be dropped from
79			// the pending list. A spent one means the funding tx was double-spent and the board
80			// action is being torn down, which will likewise drop it from the pending list.
81			match vtxo.state.kind() {
82				VtxoStateKind::Locked => vtxos.push(vtxo),
83				VtxoStateKind::Exited | VtxoStateKind::Spent => continue,
84				VtxoStateKind::Spendable => {
85					warn!("Pending board VTXO {} has unexpected state: {:?}", vtxo_id, vtxo.state);
86					debug_assert!(false, "all pending board vtxos should be locked, spent or exited");
87				}
88			}
89		}
90
91		Ok(vtxos)
92	}
93
94	/// Drives every in-progress board forward by one step or to its next park.
95	///
96	/// Each board is a [`Board`] wallet action that broadcasts the funding tx,
97	/// waits for [ark::ArkInfo::required_board_confirmations], registers with the
98	/// server, and salvages via exit near expiry. See [`crate::actions::board`].
99	pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
100		let pending = self.boards_in_progress().await?;
101		if pending.is_empty() {
102			return Ok(());
103		}
104
105		info!("Syncing {} pending boards", pending.len());
106		for board in pending {
107			let id = board.id();
108			if let Err(e) = self.drive_action(board, DriveMode::UntilParkOrDone).await {
109				warn!("Failed to sync board {}: {:#}", id, e);
110			}
111		}
112		Ok(())
113	}
114
115	async fn board(
116		&self,
117		amount: Option<Amount>,
118		user_keypair: Keypair,
119	) -> anyhow::Result<PendingBoard> {
120		let onchain = self.inner.onchain.as_ref()
121			.ok_or_else(|| anyhow!("no onchain wallet configured; cannot board"))?;
122
123		let (addr, expiry_height) = self.board_funding_address(&user_keypair).await?;
124		let fee_rate = self.inner.chain.fee_rates().await.regular;
125
126		let signed_psbt = {
127			let mut wallet = onchain.write().await;
128			let board_psbt = if let Some(amount) = amount {
129				wallet.prepare_tx(&[(addr, amount)], fee_rate).await?
130			} else {
131				wallet.prepare_drain_tx(addr, fee_rate).await?
132			};
133
134			if board_psbt.inputs.len() > MAX_NB_BOARD_FUNDING_INPUTS {
135				bail!("We need {} inputs to board, exceeding the limit of {}",
136					board_psbt.inputs.len(), MAX_NB_BOARD_FUNDING_INPUTS,
137				);
138			}
139
140			wallet.finish_psbt(board_psbt).await?
141		};
142
143		self.board_tx(signed_psbt, user_keypair, expiry_height).await
144	}
145
146	/// Returns the funding address for a board with the given keypair.
147	///
148	/// The caller can use this address to build a funding transaction, then pass it
149	/// to [Wallet::board_tx] to complete the board setup.
150	pub async fn board_funding_address(
151		&self,
152		user_keypair: &Keypair,
153	) -> anyhow::Result<(Address, BlockHeight)> {
154		let (_, ark_info) = self.require_server().await?;
155		let properties = self.inner.db.read_properties().await?.context("Missing config")?;
156		let current_height = self.inner.chain.tip().await?;
157
158		let expiry_height = current_height + ark_info.vtxo_expiry_delta as BlockHeight;
159		let builder = BoardBuilder::new(
160			user_keypair.public_key(),
161			expiry_height,
162			ark_info.server_pubkey,
163			ark_info.vtxo_exit_delta,
164		);
165
166		let addr = bitcoin::Address::from_script(
167			&builder.funding_script_pubkey(),
168			properties.network,
169		)?;
170
171		Ok((addr, expiry_height))
172	}
173
174	/// Board a [ark::Vtxo] using a signed funding PSBT.
175	///
176	/// The PSBT must be signed and send funds to the address returned by
177	/// [Wallet::board_funding_address] at output index [BOARD_FUNDING_TX_VTXO_VOUT].
178	pub async fn board_tx(
179		&self,
180		board_psbt: Psbt,
181		user_keypair: Keypair,
182		expiry_height: BlockHeight,
183	) -> anyhow::Result<PendingBoard> {
184		let (mut srv, ark_info) = self.require_server().await?;
185
186		let builder = BoardBuilder::new(
187			user_keypair.public_key(),
188			expiry_height,
189			ark_info.server_pubkey,
190			ark_info.vtxo_exit_delta,
191		);
192
193		let board_output = board_psbt.unsigned_tx.output.get(BOARD_FUNDING_TX_VTXO_VOUT as usize)
194			.context("PSBT does not have output at board funding vout index")?;
195		let expected_script = builder.funding_script_pubkey();
196		ensure!(
197			board_output.script_pubkey == expected_script,
198			"PSBT output does not pay to the expected board funding address",
199		);
200
201		let amount = board_output.value;
202		ensure!(amount >= ark_info.min_board_amount,
203			"board amount of {amount} is less than minimum board amount required by server ({})",
204			ark_info.min_board_amount,
205		);
206		let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
207		validate_and_subtract_fee(amount, fee)?;
208
209		let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
210		let builder = builder
211			.set_funding_details(amount, fee, utxo)
212			.context("error setting funding details for board")?
213			.generate_user_nonces();
214
215		let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
216			amount: amount.to_sat(),
217			utxo: bitcoin::consensus::serialize(&utxo), //TODO(stevenroose) change to own
218			expiry_height,
219			user_pubkey: user_keypair.public_key().serialize().to_vec(),
220			pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
221			funding_tx: bitcoin::consensus::serialize(&board_psbt.unsigned_tx),
222		}).await.context("error requesting board cosign")?
223			.into_inner().try_into().context("invalid cosign response from server")?;
224
225		ensure!(builder.verify_cosign_response(&cosign_resp),
226				"invalid board cosignature received from server",
227			);
228
229		// Cosign and vtxo construction need the user keypair (and the funding
230		// PSBT came from the on-chain wallet), neither of which the action can
231		// reach. Do them here, then hand the rest of the lifecycle (store +
232		// broadcast + confirm + register) to a crash-safe wallet action.
233		let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
234
235		let onchain_fee = board_psbt.fee()?;
236		let movement_id = self.inner.movements.new_movement_with_update(
237			Subsystem::BOARD,
238			BoardMovement::Board.to_string(),
239			MovementUpdate::new()
240				.intended_balance(amount.to_signed()?)
241				.effective_balance(vtxo.amount().to_signed()?)
242				.fee(fee)
243				.produced_vtxo(&vtxo)
244				.metadata(BoardMovement::metadata(utxo, onchain_fee)),
245		).await?;
246
247		let tx = board_psbt.extract_tx()?;
248		let vtxo_id = vtxo.id();
249		// The board amount net of the board fee, i.e. the vtxo value. This is
250		// what `PendingBoard` has always reported (not the gross funding output).
251		let vtxo_amount = vtxo.amount();
252		let board = Board {
253			id: board_action_id(utxo),
254			funding_tx: tx,
255			vtxo_id,
256			amount: vtxo_amount,
257			movement_id,
258			progress: Progress::Broadcasting { signed_vtxo: vtxo },
259		};
260
261		// Persist the checkpoint before any vtxo lock so a crash between here and
262		// `drive_action` leaves something to resume (the action stores the vtxo
263		// and broadcasts), rather than an orphaned lock.
264		self.inner.db.upsert_wallet_action_checkpoint(&board.id, &board.clone().into()).await?;
265
266		let pending = PendingBoard {
267			funding_tx: board.funding_tx.clone(),
268			vtxos: vec![vtxo_id],
269			amount: vtxo_amount,
270			movement_id,
271		};
272
273		// The checkpoint above is durable, so the board is accepted: sync will
274		// drive it to completion. The initial drive is best-effort, so don't
275		// propagate its error (a retry would fund a duplicate board).
276		match self.drive_action(board, DriveMode::UntilParkOrDone).await {
277			Ok(()) => info!("Board broadcasted"),
278			Err(e) => warn!("Initial board drive failed, sync will retry: {:#}", e),
279		}
280		Ok(pending)
281	}
282}