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 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 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 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 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 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 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 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 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 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 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 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), 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 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 let vtxo_amount = vtxo.amount();
297
298 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 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 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}