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;
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		self.boards_in_progress().await?
41			.into_iter()
42			.map(|b| Ok(PendingBoard {
43				funding_tx: b.funding()?.clone(),
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 whose funding
60	/// transaction has reached the chain. There is a lag time between when a board is
61	/// created and when it becomes spendable.
62	///
63	/// A board bark does not broadcast itself is left out until its funding
64	/// transaction shows up: nothing has moved on-chain yet, and the party holding the
65	/// signatures may never send it.
66	///
67	/// See [ark::ArkInfo::required_board_confirmations] and [Wallet::sync_pending_boards].
68	pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
69		let boards = self.boards_in_progress().await?;
70
71		let mut vtxos = Vec::with_capacity(boards.len());
72		for board in boards {
73			// A board still in `Broadcasting` has not been seen on the network, so
74			// nothing has moved and its vtxo may not even be stored yet.
75			if matches!(board.progress, Progress::Broadcasting { .. }) {
76				continue;
77			}
78			let vtxo_id = board.vtxo_id;
79			let vtxo = self.get_vtxo_by_id(vtxo_id).await?;
80			// We can silently filter out exited VTXOs, next time we sync they will be dropped from
81			// the pending list. A spent one means the funding tx was double-spent and the board
82			// action is being torn down, which will likewise drop it from the pending list.
83			match vtxo.state.kind() {
84				VtxoStateKind::Locked => vtxos.push(vtxo),
85				VtxoStateKind::Exited | VtxoStateKind::Spent => continue,
86				VtxoStateKind::Spendable => {
87					warn!("Pending board VTXO {} has unexpected state: {:?}", vtxo_id, vtxo.state);
88					debug_assert!(false, "all pending board vtxos should be locked, spent or exited");
89				}
90			}
91		}
92
93		Ok(vtxos)
94	}
95
96	/// Drives every in-progress board forward by one step or to its next park.
97	///
98	/// Each board is a [`Board`] wallet action that broadcasts the funding tx,
99	/// waits for [ark::ArkInfo::required_board_confirmations], registers with the
100	/// server, and salvages via exit near expiry. See [`crate::actions::board`].
101	pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
102		let pending = self.boards_in_progress().await?;
103		if pending.is_empty() {
104			return Ok(());
105		}
106
107		info!("Syncing {} pending boards", pending.len());
108		for board in pending {
109			let id = board.id();
110			if let Err(e) = self.drive_action(board, DriveMode::UntilParkOrDone).await {
111				warn!("Failed to sync board {}: {:#}", id, e);
112			}
113		}
114		Ok(())
115	}
116
117	async fn board(
118		&self,
119		amount: Option<Amount>,
120		user_keypair: Keypair,
121	) -> anyhow::Result<PendingBoard> {
122		let onchain = self.inner.onchain.as_ref()
123			.ok_or_else(|| anyhow!("no onchain wallet configured; cannot board"))?;
124
125		let (addr, expiry_height) = self.board_funding_address(&user_keypair).await?;
126		let fee_rate = self.inner.chain.fee_rates().await.regular;
127
128		let signed_psbt = {
129			let mut wallet = onchain.write().await;
130			let board_psbt = if let Some(amount) = amount {
131				wallet.prepare_tx(&[(addr, amount)], fee_rate).await?
132			} else {
133				wallet.prepare_drain_tx(addr, fee_rate).await?
134			};
135
136			if board_psbt.inputs.len() > MAX_NB_BOARD_FUNDING_INPUTS {
137				bail!("We need {} inputs to board, exceeding the limit of {}",
138					board_psbt.inputs.len(), MAX_NB_BOARD_FUNDING_INPUTS,
139				);
140			}
141
142			wallet.finish_psbt(board_psbt).await?
143		};
144
145		self.board_psbt(signed_psbt, user_keypair, expiry_height).await
146	}
147
148	/// Returns the funding address for a board with the given keypair.
149	///
150	/// The caller can use this address to build a funding transaction, then pass it
151	/// to [Wallet::board_psbt] to complete the board setup.
152	pub async fn board_funding_address(
153		&self,
154		user_keypair: &Keypair,
155	) -> anyhow::Result<(Address, BlockHeight)> {
156		let (_, ark_info) = self.require_server().await?;
157		let properties = self.inner.db.read_properties().await?.context("Missing config")?;
158		let current_height = self.inner.chain.tip().await?;
159
160		let expiry_height = current_height + ark_info.vtxo_lifetime as BlockHeight;
161		let builder = BoardBuilder::new(
162			user_keypair.public_key(),
163			expiry_height,
164			ark_info.server_pubkey,
165			ark_info.vtxo_exit_delta,
166		);
167
168		let addr = bitcoin::Address::from_script(
169			&builder.funding_script_pubkey(),
170			properties.network,
171		)?;
172
173		Ok((addr, expiry_height))
174	}
175
176	#[deprecated(note = "use board_psbt instead")]
177	pub async fn board_tx(
178		&self,
179		board_psbt: Psbt,
180		user_keypair: Keypair,
181		expiry_height: BlockHeight,
182	) -> anyhow::Result<PendingBoard> {
183		self.board_psbt(board_psbt, user_keypair, expiry_height).await
184	}
185
186	/// Board a [ark::Vtxo] from a funding PSBT.
187	///
188	/// The PSBT must pay to the address returned by [Wallet::board_funding_address].
189	/// It may have been built anywhere: the board output is found by matching the
190	/// [BoardBuilder] funding script-pubkey, so it may sit at any index alongside
191	/// any other outputs.
192	///
193	/// Broadcasting follows from the PSBT rather than being chosen by the caller. A
194	/// finalised PSBT bark broadcasts itself; an unfinalised one leaves the missing
195	/// signatures, and so the broadcast, with another party, and bark waits for the
196	/// transaction to appear.
197	///
198	/// The vtxo commits to `psbt.unsigned_tx.compute_txid()` and bark watches for
199	/// nothing else, so a transaction paying the same board output under a different
200	/// txid — added input, fee bump, payjoin sender falling back to its original —
201	/// goes unnoticed: the board waits forever while the funds land in an output no
202	/// vtxo tracks. The other party must therefore report any change, and the caller
203	/// call this again with the updated PSBT and the same `user_keypair` and
204	/// `expiry_height`; the abandoned board stays pending until its vtxo expires.
205	///
206	/// A receiver contributing its own input, signed `SIGHASH_ALL`, would bind the
207	/// sender to the boarded transaction
208	///
209	/// Inputs that are not natively segwit gain a scriptSig when finalised, changing
210	/// the txid, so a PSBT carrying one cannot be boarded here.
211	pub async fn board_psbt(
212		&self,
213		board_psbt: Psbt,
214		user_keypair: Keypair,
215		expiry_height: BlockHeight,
216	) -> anyhow::Result<PendingBoard> {
217		let (mut srv, ark_info) = self.require_server().await?;
218
219		let builder = BoardBuilder::new(
220			user_keypair.public_key(),
221			expiry_height,
222			ark_info.server_pubkey,
223			ark_info.vtxo_exit_delta,
224		);
225
226		// The server caps funding inputs. `board` checks this for txs we build; a
227		// tx built elsewhere has to be checked here too.
228		ensure!(board_psbt.unsigned_tx.input.len() <= MAX_NB_BOARD_FUNDING_INPUTS,
229			"funding tx has {} inputs, exceeding the limit of {}",
230			board_psbt.unsigned_tx.input.len(), MAX_NB_BOARD_FUNDING_INPUTS,
231		);
232
233		// Locate the board output by script-pubkey rather than a fixed vout: a tx
234		// built elsewhere orders its outputs freely. Ours puts it at vout 0. Paying
235		// the script twice is refused rather than resolved to the first match, which
236		// would board one output and leave the other tracked by nothing.
237		let expected_script = builder.funding_script_pubkey();
238		let mut board_outputs = board_psbt.unsigned_tx.output.iter().enumerate()
239			.filter(|(_, o)| o.script_pubkey == expected_script);
240		let (vout, board_output) = board_outputs.next()
241			.context("PSBT output does not pay to the expected board funding address")?;
242		ensure!(board_outputs.next().is_none(),
243			"PSBT pays to the board funding address more than once",
244		);
245		let vout = vout as u32;
246
247		let amount = board_output.value;
248		ensure!(amount >= ark_info.min_board_amount,
249			"board amount of {amount} is less than minimum board amount required by server ({})",
250			ark_info.min_board_amount,
251		);
252		let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
253		validate_and_subtract_fee(amount, fee)?;
254
255		let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), vout);
256		let builder = builder
257			.set_funding_details(amount, fee, utxo)
258			.context("error setting funding details for board")?
259			.generate_user_nonces();
260
261		let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
262			amount: amount.to_sat(),
263			utxo: bitcoin::consensus::serialize(&utxo), //TODO(stevenroose) change to own
264			expiry_height,
265			user_pubkey: user_keypair.public_key().serialize().to_vec(),
266			pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
267			funding_tx: bitcoin::consensus::serialize(&board_psbt.unsigned_tx),
268		}).await.context("error requesting board cosign")?
269			.into_inner().try_into().context("invalid cosign response from server")?;
270
271		ensure!(builder.verify_cosign_response(&cosign_resp),
272				"invalid board cosignature received from server",
273			);
274
275		// Cosign and vtxo construction need the user keypair (and the funding
276		// PSBT came from the on-chain wallet), neither of which the action can
277		// reach. Do them here, then hand the rest of the lifecycle (store +
278		// broadcast + confirm + register) to a crash-safe wallet action.
279		let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
280
281		let onchain_fee = board_psbt.fee()?;
282		let movement_id = self.inner.movements.new_movement_with_update(
283			Subsystem::BOARD,
284			BoardMovement::Board.to_string(),
285			MovementUpdate::new()
286				.intended_balance(amount.to_signed()?)
287				.effective_balance(vtxo.amount().to_signed()?)
288				.fee(fee)
289				.produced_vtxo(&vtxo)
290				.metadata(BoardMovement::metadata(utxo, onchain_fee)),
291		).await?;
292
293		let vtxo_id = vtxo.id();
294		// The board amount net of the board fee, i.e. the vtxo value. This is
295		// what `PendingBoard` has always reported (not the gross funding output).
296		let vtxo_amount = vtxo.amount();
297
298		// `Broadcasting` broadcasts only a finalised proposal, so boards bark won't
299		// send start here too. `funding_tx` is never written: it exists to read
300		// checkpoints from before `funding_psbt`, which a bark that predates it cannot
301		// read in turn.
302		let board = Board {
303			id: board_action_id(utxo),
304			funding_tx: None,
305			funding_psbt: Some(board_psbt),
306			vtxo_id,
307			amount: vtxo_amount,
308			movement_id,
309			progress: Progress::Broadcasting { signed_vtxo: vtxo },
310		};
311
312		// Persist the checkpoint before any vtxo lock so a crash between here and
313		// `drive_action` leaves something to resume (the action stores the vtxo
314		// and broadcasts), rather than an orphaned lock.
315		self.inner.db.upsert_wallet_action_checkpoint(&board.id, &board.clone().into()).await?;
316
317		let pending = PendingBoard {
318			funding_tx: board.funding()?.clone(),
319			vtxos: vec![vtxo_id],
320			amount: vtxo_amount,
321			movement_id,
322		};
323
324		// The checkpoint above is durable, so the board is accepted: sync will
325		// drive it to completion. The initial drive is best-effort, so don't
326		// propagate its error (a retry would fund a duplicate board).
327		match self.drive_action(board, DriveMode::UntilParkOrDone).await {
328			Ok(()) => info!("Board accepted"),
329			Err(e) => warn!("Initial board drive failed, sync will retry: {:#}", e),
330		}
331		Ok(pending)
332	}
333}