1use anyhow::Context;
2use bdk_esplora::esplora_client::Amount;
3use bitcoin::key::Keypair;
4use bitcoin::{Address, OutPoint, Psbt};
5use log::{debug, error, info, trace, warn};
6
7use ark::{ProtocolEncoding, VtxoId};
8use ark::board::{BoardBuilder, BOARD_FUNDING_TX_VTXO_VOUT};
9use ark::fees::validate_and_subtract_fee;
10use bitcoin_ext::{BlockHeight, TxStatus};
11use server_rpc::protos;
12
13use crate::{onchain, Wallet, WalletVtxo};
14use crate::movement::MovementStatus;
15use crate::movement::update::MovementUpdate;
16use crate::persist::models::PendingBoard;
17use crate::subsystem::{BoardMovement, Subsystem};
18use crate::vtxo::{VtxoState, VtxoStateKind};
19
20impl Wallet {
21 pub async fn board_amount(
25 &self,
26 onchain: &mut dyn onchain::Board,
27 amount: Amount,
28 ) -> anyhow::Result<PendingBoard> {
29 let (user_keypair, _) = self.derive_store_next_keypair().await?;
30 self.board(onchain, Some(amount), user_keypair).await
31 }
32
33 pub async fn board_all(
35 &self,
36 onchain: &mut dyn onchain::Board,
37 ) -> anyhow::Result<PendingBoard> {
38 let (user_keypair, _) = self.derive_store_next_keypair().await?;
39 self.board(onchain, None, user_keypair).await
40 }
41
42 pub async fn pending_boards(&self) -> anyhow::Result<Vec<PendingBoard>> {
43 let boarding_vtxo_ids = self.inner.db.get_all_pending_board_ids().await?;
44 let mut boards = Vec::with_capacity(boarding_vtxo_ids.len());
45 for vtxo_id in boarding_vtxo_ids {
46 let board = self.inner.db.get_pending_board_by_vtxo_id(vtxo_id).await?
47 .expect("id just retrieved from db");
48 boards.push(board);
49 }
50 Ok(boards)
51 }
52
53 pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
58 let vtxo_ids = self.pending_boards().await?.into_iter()
59 .flat_map(|b| b.vtxos.into_iter())
60 .collect::<Vec<_>>();
61
62 let mut vtxos = Vec::with_capacity(vtxo_ids.len());
63 for vtxo_id in vtxo_ids {
64 let vtxo = self.get_vtxo_by_id(vtxo_id).await
65 .expect("vtxo id just got retrieved from db");
66 match vtxo.state.kind() {
69 VtxoStateKind::Locked => vtxos.push(vtxo),
70 VtxoStateKind::Exited => continue,
71 VtxoStateKind::Spendable | VtxoStateKind::Spent => {
72 warn!("Pending board VTXO {} has unexpected state: {:?}", vtxo_id, vtxo.state);
73 debug_assert!(false, "all pending board vtxos should be locked or exited");
74 }
75 }
76 }
77
78 Ok(vtxos)
79 }
80
81 pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
85 let (_, ark_info) = self.require_server().await?;
86 let current_height = self.inner.chain.tip().await?;
87 let unregistered_boards = self.pending_boards().await?;
88 let mut registered_boards = 0;
89
90 if unregistered_boards.is_empty() {
91 return Ok(());
92 }
93
94 trace!("Attempting registration of sufficiently confirmed boards");
95
96 for board in unregistered_boards {
97 let [vtxo_id] = board.vtxos.try_into()
98 .map_err(|_| anyhow!("multiple board vtxos is not supported yet"))?;
99
100 let vtxo = self.get_vtxo_by_id(vtxo_id).await?;
105 if vtxo.state.kind() == VtxoStateKind::Exited {
106 debug!("Removing pending_board for exited VTXO {}", vtxo_id);
107 self.inner.db.remove_pending_board(&vtxo_id).await?;
108 self.inner.movements.finish_movement(
109 board.movement_id, MovementStatus::Failed,
110 ).await?;
111 continue;
112 }
113
114 let anchor = vtxo.chain_anchor();
115 let confs = match self.inner.chain.tx_status(anchor.txid).await {
116 Ok(TxStatus::Confirmed(block_ref)) => Some(current_height - (block_ref.height - 1)),
117 Ok(TxStatus::Mempool) => Some(0),
118 Ok(TxStatus::NotFound) => None,
119 Err(_) => None,
120 };
121
122 if let Some(confs) = confs {
123 if confs >= ark_info.required_board_confirmations as BlockHeight {
124 if let Err(e) = self.register_board(vtxo.id()).await {
125 warn!("Failed to register board {}: {:#}", vtxo.id(), e);
126 } else {
127 info!("Registered board {}", vtxo.id());
128 registered_boards += 1;
129 continue;
130 }
131 }
132 }
133
134 if vtxo.expiry_height() < current_height + ark_info.required_board_confirmations as BlockHeight {
141 let is_exiting = self.exit_mgr().is_exiting(vtxo.id()).await;
142 if !is_exiting {
143 warn!("VTXO {} expired before its board was confirmed, marking VTXO for exit", vtxo.id());
144 self.inner.exit.start_exit_for_vtxos(&[vtxo.vtxo]).await?;
145 self.inner.movements.update_movement(
146 board.movement_id, MovementUpdate::new().exited_vtxo(vtxo_id),
147 ).await?;
148 }
149 }
150 };
151
152 if registered_boards > 0 {
153 info!("Registered {registered_boards} sufficiently confirmed boards");
154 }
155 Ok(())
156 }
157
158 async fn board(
159 &self,
160 wallet: &mut dyn onchain::Board,
161 amount: Option<Amount>,
162 user_keypair: Keypair,
163 ) -> anyhow::Result<PendingBoard> {
164 let (addr, expiry_height) = self.board_funding_address(&user_keypair).await?;
165 let fee_rate = self.inner.chain.fee_rates().await.regular;
166
167 let board_psbt = if let Some(amount) = amount {
168 wallet.prepare_tx(&[(addr, amount)], fee_rate)?
169 } else {
170 wallet.prepare_drain_tx(addr, fee_rate)?
171 };
172
173 let signed_psbt = wallet.finish_psbt(board_psbt).await?;
174 self.board_tx(signed_psbt, user_keypair, expiry_height).await
175 }
176
177 pub async fn board_funding_address(
182 &self,
183 user_keypair: &Keypair,
184 ) -> anyhow::Result<(Address, BlockHeight)> {
185 let (_, ark_info) = self.require_server().await?;
186 let properties = self.inner.db.read_properties().await?.context("Missing config")?;
187 let current_height = self.inner.chain.tip().await?;
188
189 let expiry_height = current_height + ark_info.vtxo_expiry_delta as BlockHeight;
190 let builder = BoardBuilder::new(
191 user_keypair.public_key(),
192 expiry_height,
193 ark_info.server_pubkey,
194 ark_info.vtxo_exit_delta,
195 );
196
197 let addr = bitcoin::Address::from_script(
198 &builder.funding_script_pubkey(),
199 properties.network,
200 )?;
201
202 Ok((addr, expiry_height))
203 }
204
205 pub async fn board_tx(
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 let board_output = board_psbt.unsigned_tx.output.get(BOARD_FUNDING_TX_VTXO_VOUT as usize)
225 .context("PSBT does not have output at board funding vout index")?;
226 let expected_script = builder.funding_script_pubkey();
227 ensure!(
228 board_output.script_pubkey == expected_script,
229 "PSBT output does not pay to the expected board funding address",
230 );
231
232 let amount = board_output.value;
233 ensure!(amount >= ark_info.min_board_amount,
234 "board amount of {amount} is less than minimum board amount required by server ({})",
235 ark_info.min_board_amount,
236 );
237 let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
238 validate_and_subtract_fee(amount, fee)?;
239
240 let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
241 let builder = builder
242 .set_funding_details(amount, fee, utxo)
243 .context("error setting funding details for board")?
244 .generate_user_nonces();
245
246 let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
247 amount: amount.to_sat(),
248 utxo: bitcoin::consensus::serialize(&utxo), expiry_height,
250 user_pubkey: user_keypair.public_key().serialize().to_vec(),
251 pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
252 }).await.context("error requesting board cosign")?
253 .into_inner().try_into().context("invalid cosign response from server")?;
254
255 ensure!(builder.verify_cosign_response(&cosign_resp),
256 "invalid board cosignature received from server",
257 );
258
259 let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
261
262 let onchain_fee = board_psbt.fee()?;
263 let movement_id = self.inner.movements.new_movement_with_update(
264 Subsystem::BOARD,
265 BoardMovement::Board.to_string(),
266 MovementUpdate::new()
267 .intended_balance(amount.to_signed()?)
268 .effective_balance(vtxo.amount().to_signed()?)
269 .fee(fee)
270 .produced_vtxo(&vtxo)
271 .metadata(BoardMovement::metadata(utxo, onchain_fee)),
272 ).await?;
273 self.store_locked_vtxos(
274 [&vtxo],
275 Some(crate::vtxo::VtxoLockHolder::Movement { id: movement_id }),
276 ).await?;
277
278 let tx = board_psbt.extract_tx()?;
279 self.inner.db.store_pending_board(&vtxo, &tx, movement_id).await?;
280
281 trace!("Broadcasting board tx: {}", bitcoin::consensus::encode::serialize_hex(&tx));
282 self.inner.chain.broadcast_tx(&tx).await?;
283
284 info!("Board broadcasted");
285 Ok(self.inner.db.get_pending_board_by_vtxo_id(vtxo.id()).await?.expect("board should be stored"))
286 }
287
288 async fn register_board(&self, vtxo_id: VtxoId) -> anyhow::Result<()> {
290 trace!("Attempting to register board {} to server", vtxo_id);
291 let (mut srv, _) = self.require_server().await?;
292
293 let vtxo = self.inner.db.get_full_vtxo(vtxo_id).await?
296 .with_context(|| format!("VTXO doesn't exist: {}", vtxo_id))?;
297
298 srv.client.register_board_vtxo(protos::BoardVtxoRequest {
300 board_vtxo: vtxo.serialize(),
301 }).await.context("error registering board with the Ark server")?;
302
303 self.inner.db.update_vtxo_state_checked(
306 vtxo.id(), VtxoState::Spendable, &VtxoStateKind::UNSPENT_STATES,
307 ).await?;
308
309 if let Err(e) = self.post_recovery_vtxo_ids([vtxo.id()]).await {
311 error!("Failed to post recovery vtxo ID to server: {:#}", e);
312 }
313
314 let board = self.inner.db.get_pending_board_by_vtxo_id(vtxo.id()).await?
315 .context("pending board not found")?;
316
317 self.inner.movements.finish_movement(board.movement_id, MovementStatus::Successful).await?;
319 self.inner.db.remove_pending_board(&vtxo.id()).await?;
320
321 Ok(())
322 }
323}