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>> {
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 Err(e) => match board.progress {
74 Progress::Broadcasting { .. } => continue,
75 Progress::Confirming { .. } => return Err(e),
76 },
77 };
78 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 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_psbt(signed_psbt, user_keypair, expiry_height).await
144 }
145
146 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_lifetime 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 #[deprecated(note = "use board_psbt instead")]
175 pub async fn board_tx(
176 &self,
177 board_psbt: Psbt,
178 user_keypair: Keypair,
179 expiry_height: BlockHeight,
180 ) -> anyhow::Result<PendingBoard> {
181 self.board_psbt(board_psbt, user_keypair, expiry_height).await
182 }
183
184 pub async fn board_psbt(
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 ensure!(board_psbt.unsigned_tx.input.len() <= MAX_NB_BOARD_FUNDING_INPUTS,
227 "funding tx has {} inputs, exceeding the limit of {}",
228 board_psbt.unsigned_tx.input.len(), MAX_NB_BOARD_FUNDING_INPUTS,
229 );
230
231 let expected_script = builder.funding_script_pubkey();
236 let mut board_outputs = board_psbt.unsigned_tx.output.iter().enumerate()
237 .filter(|(_, o)| o.script_pubkey == expected_script);
238 let (vout, board_output) = board_outputs.next()
239 .context("PSBT output does not pay to the expected board funding address")?;
240 ensure!(board_outputs.next().is_none(),
241 "PSBT pays to the board funding address more than once",
242 );
243 let vout = vout as u32;
244
245 let amount = board_output.value;
246 ensure!(amount >= ark_info.min_board_amount,
247 "board amount of {amount} is less than minimum board amount required by server ({})",
248 ark_info.min_board_amount,
249 );
250 let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
251 validate_and_subtract_fee(amount, fee)?;
252
253 let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), vout);
254 let builder = builder
255 .set_funding_details(amount, fee, utxo)
256 .context("error setting funding details for board")?
257 .generate_user_nonces();
258
259 let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
260 amount: amount.to_sat(),
261 utxo: bitcoin::consensus::serialize(&utxo), expiry_height,
263 user_pubkey: user_keypair.public_key().serialize().to_vec(),
264 pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
265 funding_tx: bitcoin::consensus::serialize(&board_psbt.unsigned_tx),
266 }).await.context("error requesting board cosign")?
267 .into_inner().try_into().context("invalid cosign response from server")?;
268
269 ensure!(builder.verify_cosign_response(&cosign_resp),
270 "invalid board cosignature received from server",
271 );
272
273 let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
278
279 let onchain_fee = board_psbt.fee()?;
280 let movement_id = self.inner.movements.new_movement_with_update(
281 Subsystem::BOARD,
282 BoardMovement::Board.to_string(),
283 MovementUpdate::new()
284 .intended_balance(amount.to_signed()?)
285 .effective_balance(vtxo.amount().to_signed()?)
286 .fee(fee)
287 .produced_vtxo(&vtxo)
288 .metadata(BoardMovement::metadata(utxo, onchain_fee)),
289 ).await?;
290
291 let vtxo_id = vtxo.id();
292 let vtxo_amount = vtxo.amount();
295
296 let board = Board {
301 id: board_action_id(utxo),
302 funding_tx: None,
303 funding_psbt: Some(board_psbt),
304 vtxo_id,
305 amount: vtxo_amount,
306 movement_id,
307 progress: Progress::Broadcasting { signed_vtxo: vtxo },
308 };
309
310 self.inner.db.upsert_wallet_action_checkpoint(&board.id, &board.clone().into()).await?;
314
315 let pending = PendingBoard {
316 funding_tx: board.funding()?.clone(),
317 vtxos: vec![vtxo_id],
318 amount: vtxo_amount,
319 movement_id,
320 };
321
322 match self.drive_action(board, DriveMode::UntilParkOrDone).await {
326 Ok(()) => info!("Board accepted"),
327 Err(e) => warn!("Initial board drive failed, sync will retry: {:#}", e),
328 }
329 Ok(pending)
330 }
331}