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 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 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 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_tx(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_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 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), 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 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 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 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 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}