1use anyhow::Context;
2use bdk_esplora::esplora_client::Amount;
3use bitcoin::key::Keypair;
4use bitcoin::OutPoint;
5use log::{info, trace, warn};
6
7use ark::ProtocolEncoding;
8use ark::board::{BoardBuilder, BOARD_FUNDING_TX_VTXO_VOUT};
9use ark::fees::validate_and_subtract_fee;
10use ark::vtxo::VtxoRef;
11use bitcoin_ext::{BlockHeight, TxStatus};
12use server_rpc::protos;
13
14use crate::{onchain, Wallet, WalletVtxo};
15use crate::movement::MovementStatus;
16use crate::movement::update::MovementUpdate;
17use crate::persist::models::PendingBoard;
18use crate::subsystem::{BoardMovement, Subsystem};
19use crate::vtxo::{VtxoState, VtxoStateKind};
20
21impl Wallet {
22 pub async fn board_amount(
26 &self,
27 onchain: &mut dyn onchain::Board,
28 amount: Amount,
29 ) -> anyhow::Result<PendingBoard> {
30 let (user_keypair, _) = self.derive_store_next_keypair().await?;
31 self.board(onchain, Some(amount), user_keypair).await
32 }
33
34 pub async fn board_all(
36 &self,
37 onchain: &mut dyn onchain::Board,
38 ) -> anyhow::Result<PendingBoard> {
39 let (user_keypair, _) = self.derive_store_next_keypair().await?;
40 self.board(onchain, None, user_keypair).await
41 }
42
43 pub async fn pending_boards(&self) -> anyhow::Result<Vec<PendingBoard>> {
44 let boarding_vtxo_ids = self.db.get_all_pending_board_ids().await?;
45 let mut boards = Vec::with_capacity(boarding_vtxo_ids.len());
46 for vtxo_id in boarding_vtxo_ids {
47 let board = self.db.get_pending_board_by_vtxo_id(vtxo_id).await?
48 .expect("id just retrieved from db");
49 boards.push(board);
50 }
51 Ok(boards)
52 }
53
54 pub async fn pending_board_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
59 let vtxo_ids = self.pending_boards().await?.into_iter()
60 .flat_map(|b| b.vtxos.into_iter())
61 .collect::<Vec<_>>();
62
63 let mut vtxos = Vec::with_capacity(vtxo_ids.len());
64 for vtxo_id in vtxo_ids {
65 let vtxo = self.get_vtxo_by_id(vtxo_id).await
66 .expect("vtxo id just got retrieved from db");
67 vtxos.push(vtxo);
68 }
69
70 debug_assert!(vtxos.iter().all(|v| matches!(v.state.kind(), VtxoStateKind::Locked)),
71 "all pending board vtxos should be locked"
72 );
73
74 Ok(vtxos)
75 }
76
77 pub async fn sync_pending_boards(&self) -> anyhow::Result<()> {
81 let (_, ark_info) = self.require_server().await?;
82 let current_height = self.chain.tip().await?;
83 let unregistered_boards = self.pending_boards().await?;
84 let mut registered_boards = 0;
85
86 if unregistered_boards.is_empty() {
87 return Ok(());
88 }
89
90 trace!("Attempting registration of sufficiently confirmed boards");
91
92 for board in unregistered_boards {
93 let [vtxo_id] = board.vtxos.try_into()
94 .map_err(|_| anyhow!("multiple board vtxos is not supported yet"))?;
95
96 let vtxo = self.get_vtxo_by_id(vtxo_id).await?;
97
98 let anchor = vtxo.chain_anchor();
99 let confs = match self.chain.tx_status(anchor.txid).await {
100 Ok(TxStatus::Confirmed(block_ref)) => Some(current_height - (block_ref.height - 1)),
101 Ok(TxStatus::Mempool) => Some(0),
102 Ok(TxStatus::NotFound) => None,
103 Err(_) => None,
104 };
105
106 if let Some(confs) = confs {
107 if confs >= ark_info.required_board_confirmations as BlockHeight {
108 if let Err(e) = self.register_board(vtxo.id()).await {
109 warn!("Failed to register board {}: {:#}", vtxo.id(), e);
110 } else {
111 info!("Registered board {}", vtxo.id());
112 registered_boards += 1;
113 continue;
114 }
115 }
116 }
117
118 if vtxo.expiry_height() < current_height + ark_info.required_board_confirmations as BlockHeight {
119 warn!("VTXO {} expired before its board was confirmed, removing board and marking VTXO for exit", vtxo.id());
120 self.exit.write().await.start_exit_for_vtxos(&[vtxo.vtxo]).await?;
121 self.movements.finish_movement_with_update(
122 board.movement_id,
123 MovementStatus::Failed,
124 MovementUpdate::new()
125 .exited_vtxo(vtxo_id),
126 ).await?;
127
128 self.db.remove_pending_board(&vtxo_id).await?;
129 }
130 };
131
132 if registered_boards > 0 {
133 info!("Registered {registered_boards} sufficiently confirmed boards");
134 }
135 Ok(())
136 }
137
138 async fn board(
139 &self,
140 wallet: &mut dyn onchain::Board,
141 amount: Option<Amount>,
142 user_keypair: Keypair,
143 ) -> anyhow::Result<PendingBoard> {
144 let (mut srv, ark_info) = self.require_server().await?;
145
146 let properties = self.db.read_properties().await?.context("Missing config")?;
147 let current_height = self.chain.tip().await?;
148
149 let expiry_height = current_height + ark_info.vtxo_expiry_delta as BlockHeight;
150 let builder = BoardBuilder::new(
151 user_keypair.public_key(),
152 expiry_height,
153 ark_info.server_pubkey,
154 ark_info.vtxo_exit_delta,
155 );
156
157 let addr = bitcoin::Address::from_script(
158 &builder.funding_script_pubkey(),
159 properties.network,
160 )?;
161
162 let fee_rate = self.chain.fee_rates().await.regular;
164 let (board_psbt, amount) = if let Some(amount) = amount {
165 let psbt = wallet.prepare_tx(&[(addr, amount)], fee_rate)?;
166 (psbt, amount)
167 } else {
168 let psbt = wallet.prepare_drain_tx(addr, fee_rate)?;
169 assert_eq!(psbt.unsigned_tx.output.len(), 1);
170 let amount = psbt.unsigned_tx.output[0].value;
171 (psbt, amount)
172 };
173 ensure!(amount >= ark_info.min_board_amount,
174 "board amount of {amount} is less than minimum board amount required by server ({})",
175 ark_info.min_board_amount,
176 );
177 let fee = ark_info.fees.board.calculate(amount).context("fee overflowed")?;
178 validate_and_subtract_fee(amount, fee)?;
179
180 let utxo = OutPoint::new(board_psbt.unsigned_tx.compute_txid(), BOARD_FUNDING_TX_VTXO_VOUT);
181 let builder = builder
182 .set_funding_details(amount, fee, utxo)
183 .context("error setting funding details for board")?
184 .generate_user_nonces();
185
186 let cosign_resp = srv.client.request_board_cosign(protos::BoardCosignRequest {
187 amount: amount.to_sat(),
188 utxo: bitcoin::consensus::serialize(&utxo), expiry_height,
190 user_pubkey: user_keypair.public_key().serialize().to_vec(),
191 pub_nonce: builder.user_pub_nonce().serialize().to_vec(),
192 }).await.context("error requesting board cosign")?
193 .into_inner().try_into().context("invalid cosign response from server")?;
194
195 ensure!(builder.verify_cosign_response(&cosign_resp),
196 "invalid board cosignature received from server",
197 );
198
199 let vtxo = builder.build_vtxo(&cosign_resp, &user_keypair)?;
201
202 let onchain_fee = board_psbt.fee()?;
203 let movement_id = self.movements.new_movement_with_update(
204 Subsystem::BOARD,
205 BoardMovement::Board.to_string(),
206 MovementUpdate::new()
207 .intended_balance(amount.to_signed()?)
208 .effective_balance(vtxo.amount().to_signed()?)
209 .fee(fee)
210 .produced_vtxo(&vtxo)
211 .metadata(BoardMovement::metadata(utxo, onchain_fee)),
212 ).await?;
213 self.store_locked_vtxos([&vtxo], Some(movement_id)).await?;
214
215 let tx = wallet.finish_tx(board_psbt).await?;
216 self.db.store_pending_board(&vtxo, &tx, movement_id).await?;
217
218 trace!("Broadcasting board tx: {}", bitcoin::consensus::encode::serialize_hex(&tx));
219 self.chain.broadcast_tx(&tx).await?;
220
221 info!("Board broadcasted");
222 Ok(self.db.get_pending_board_by_vtxo_id(vtxo.id()).await?.expect("board should be stored"))
223 }
224
225 async fn register_board(&self, vtxo: impl VtxoRef) -> anyhow::Result<()> {
227 trace!("Attempting to register board {} to server", vtxo.vtxo_id());
228 let (mut srv, _) = self.require_server().await?;
229
230 let wallet_vtxo;
232 let vtxo = match vtxo.as_full_vtxo() {
233 Some(v) => v,
234 None => {
235 wallet_vtxo = self.db.get_wallet_vtxo(vtxo.vtxo_id()).await?
236 .with_context(|| format!("VTXO doesn't exist: {}", vtxo.vtxo_id()))?;
237 &wallet_vtxo.vtxo
238 },
239 };
240
241 srv.client.register_board_vtxo(protos::BoardVtxoRequest {
243 board_vtxo: vtxo.serialize(),
244 }).await.context("error registering board with the Ark server")?;
245
246 self.db.update_vtxo_state_checked(
249 vtxo.id(), VtxoState::Spendable, &VtxoStateKind::UNSPENT_STATES,
250 ).await?;
251
252 let board = self.db.get_pending_board_by_vtxo_id(vtxo.id()).await?
253 .context("pending board not found")?;
254
255 self.movements.finish_movement(board.movement_id, MovementStatus::Successful).await?;
256 self.db.remove_pending_board(&vtxo.id()).await?;
257
258 Ok(())
259 }
260}