1
2use anyhow::Context;
3use bitcoin::{Amount, SignedAmount, Transaction, Txid};
4use bitcoin::hashes::Hash;
5use bitcoin::hex::DisplayHex;
6use bitcoin::secp256k1::Keypair;
7use log::{info, trace, warn};
8
9use ark::{musig, ProtocolEncoding, Vtxo, VtxoPolicy};
10use ark::arkoor::ArkoorDestination;
11use ark::attestations::OffboardRequestAttestation;
12use ark::fees::{validate_and_subtract_fee_min_dust, VtxoFeeInfo};
13use ark::offboard::{OffboardForfeitContext, OffboardRequest};
14use ark::vtxo::{Full, VtxoRef};
15use bitcoin_ext::{BlockHeight, TxStatus, P2TR_DUST};
16use server_rpc::{protos, ServerConnection, TryFromBytes};
17
18use crate::movement::manager::OnDropStatus;
19use crate::{Wallet, WalletVtxo};
20use crate::movement::update::MovementUpdate;
21use crate::movement::{MovementDestination, MovementStatus};
22use crate::persist::models::PendingOffboard;
23use crate::subsystem::{OffboardMovement, Subsystem};
24use crate::vtxo::{VtxoState, VtxoStateKind};
25
26
27impl Wallet {
28 pub async fn sync_pending_offboards(&self) -> anyhow::Result<()> {
34 let pending_offboards: Vec<PendingOffboard> = self.inner.db.get_pending_offboards().await?;
35
36 if pending_offboards.is_empty() {
37 return Ok(());
38 }
39
40 let current_height = self.inner.chain.tip().await?;
41 let required_confs = self.inner.config.offboard_required_confirmations;
42
43 trace!("Checking {} pending offboard transaction(s)", pending_offboards.len());
44
45 for pending in pending_offboards {
46 let status = self.inner.chain.tx_status(pending.offboard_txid).await;
47
48 match status {
49 Ok(TxStatus::Confirmed(block_ref)) => {
50 let confs = current_height - (block_ref.height - 1);
51 if confs < required_confs as BlockHeight {
52 trace!(
53 "Offboard tx {} has {}/{} confirmations, waiting...",
54 pending.offboard_txid, confs, required_confs,
55 );
56 continue;
57 }
58
59 info!(
60 "Offboard tx {} confirmed, finalizing movement {}",
61 pending.offboard_txid, pending.movement_id,
62 );
63
64 for vtxo_id in &pending.vtxo_ids {
66 if let Err(e) = self.inner.db.update_vtxo_state_checked(
67 *vtxo_id,
68 VtxoState::Spent,
69 &[VtxoStateKind::Locked],
70 ).await {
71 warn!("Failed to mark vtxo {} as spent: {:#}", vtxo_id, e);
72 }
73 }
74
75 if let Err(e) = self.inner.movements.finish_movement(
77 pending.movement_id,
78 MovementStatus::Successful,
79 ).await {
80 warn!("Failed to finish movement {}: {:#}", pending.movement_id, e);
81 }
82
83 self.inner.db.remove_pending_offboard(pending.movement_id).await?;
84 }
85 Ok(TxStatus::Mempool) => {
86 if required_confs == 0 {
87 info!(
88 "Offboard tx {} in mempool with 0 required confirmations, \
89 finalizing movement {}",
90 pending.offboard_txid, pending.movement_id,
91 );
92
93 for vtxo_id in &pending.vtxo_ids {
95 if let Err(e) = self.inner.db.update_vtxo_state_checked(
96 *vtxo_id,
97 VtxoState::Spent,
98 &[VtxoStateKind::Locked],
99 ).await {
100 warn!("Failed to mark vtxo {} as spent: {:#}", vtxo_id, e);
101 }
102 }
103
104 if let Err(e) = self.inner.movements.finish_movement(
106 pending.movement_id,
107 MovementStatus::Successful,
108 ).await {
109 warn!("Failed to finish movement {}: {:#}", pending.movement_id, e);
110 }
111
112 self.inner.db.remove_pending_offboard(pending.movement_id).await?;
113 } else {
114 trace!(
115 "Offboard tx {} still in mempool, waiting...",
116 pending.offboard_txid,
117 );
118 }
119 }
120 Ok(TxStatus::NotFound) => {
121 let age = chrono::Local::now() - pending.created_at;
125 if age < chrono::Duration::hours(1) {
126 trace!(
127 "Offboard tx {} not found, but only {} minutes old — waiting...",
128 pending.offboard_txid, age.num_minutes(),
129 );
130 continue;
131 }
132
133 warn!(
134 "Offboard tx {} not found after {} minutes, canceling movement {}",
135 pending.offboard_txid, age.num_minutes(), pending.movement_id,
136 );
137
138 for vtxo_id in &pending.vtxo_ids {
140 if let Err(e) = self.inner.db.update_vtxo_state_checked(
141 *vtxo_id,
142 VtxoState::Spendable,
143 &[VtxoStateKind::Locked],
144 ).await {
145 warn!("Failed to restore vtxo {} to spendable: {:#}", vtxo_id, e);
146 }
147 }
148
149 if let Err(e) = self.inner.movements.finish_movement(
151 pending.movement_id,
152 MovementStatus::Failed,
153 ).await {
154 warn!("Failed to fail movement {}: {:#}", pending.movement_id, e);
155 }
156
157 self.inner.db.remove_pending_offboard(pending.movement_id).await?;
158 }
159 Err(e) => {
160 warn!(
161 "Failed to check status of offboard tx {}: {:#}",
162 pending.offboard_txid, e,
163 );
164 }
165 }
166 }
167
168 Ok(())
169 }
170
171 async fn offboard_inner(
172 &self,
173 srv: &mut ServerConnection,
174 vtxos: &[impl AsRef<Vtxo<Full>>],
175 vtxo_keys: &[Keypair],
176 req: &OffboardRequest,
177 ) -> anyhow::Result<Transaction> {
178 self.register_vtxo_transactions_with_server(&vtxos).await?;
180
181 let input_ids = vtxos.iter().map(|v| v.as_ref().id()).collect::<Vec<_>>();
182 let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
183 offboard: Some(req.into()),
184 input_vtxo_ids: input_ids.iter()
185 .map(|id| id.to_bytes().to_vec())
186 .collect(),
187 attestation: vtxo_keys.iter()
188 .map(|k| OffboardRequestAttestation::new(req, &input_ids, k).serialize())
189 .collect(),
190 }).await.context("prepare offboard request failed")?.into_inner();
191 let unsigned_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
192 &prep_resp.offboard_tx,
193 ).with_context(|| format!(
194 "received invalid unsigned offboard tx from server: {}", prep_resp.offboard_tx.as_hex(),
195 ))?;
196 let offboard_txid = unsigned_offboard_tx.compute_txid();
197 info!("Received unsigned offboard tx {} from server", offboard_txid);
198 let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
199 Ok(musig::PublicNonce::from_bytes(&n)
200 .context("received invalid public cosign nonce from server")?)
201 }).collect::<anyhow::Result<Vec<_>>>()?;
202
203 let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_offboard_tx);
204 ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
205
206 let sigs = ctx.user_sign_forfeits(&vtxo_keys, &forfeit_cosign_nonces);
207
208 let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
209 offboard_txid: offboard_txid.as_byte_array().to_vec(),
210 user_nonces: sigs.public_nonces.iter().map(|n| n.serialize().to_vec()).collect(),
211 partial_signatures: sigs.partial_signatures.iter()
212 .map(|s| s.serialize().to_vec())
213 .collect(),
214 }).await.context("error sending offboard forfeit signatures to server")?.into_inner();
215
216 let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
217 &finish_resp.signed_offboard_tx,
218 ).with_context(|| format!(
219 "received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
220 ))?;
221 if signed_offboard_tx.compute_txid() != offboard_txid {
222 bail!("Signed offboard tx received from server is different from \
223 unsigned tx we forfeited for: unsigned={}, signed={}",
224 prep_resp.offboard_tx.as_hex(), finish_resp.signed_offboard_tx.as_hex(),
225 );
226 }
227
228 self.inner.chain.broadcast_tx(&signed_offboard_tx).await.with_context(|| format!(
230 "error broadcasting offboard tx {} (tx={})",
231 offboard_txid, finish_resp.signed_offboard_tx.as_hex(),
232 ))?;
233
234 Ok(signed_offboard_tx)
235 }
236
237 pub async fn send_onchain(
239 &self,
240 destination: bitcoin::Address,
241 amount: Amount,
242 ) -> anyhow::Result<Txid> {
243 if amount < P2TR_DUST {
244 bail!("it doesn't make sense to send dust");
245 }
246
247 let (mut srv, ark) = self.require_server().await?;
248 let offboard_feerate = srv.offboard_feerate().await?;
249
250 let destination_spk = destination.script_pubkey();
251 let (vtxos, fee) = self.select_vtxos_to_cover_with_fee(amount, |a, v| {
252 ark.fees.offboard.calculate(&destination_spk, a, offboard_feerate, v)
253 .ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
254 }).await?;
255 let required_amount = amount + fee;
256
257 info!("We can only offboard whole VTXOs, so we will make an arkoor tx first...");
258
259 let offboard_pubkey = self.derive_store_next_keypair().await
261 .context("failed to create new keypair")?.0;
262 let offboard_dest = ArkoorDestination {
263 total_amount: required_amount,
264 policy: VtxoPolicy::new_pubkey(offboard_pubkey.public_key()),
265 };
266 let arkoor = self.create_checkpointed_arkoor_with_vtxos(offboard_dest, vtxos.into_iter())
267 .await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
268
269 self.store_spendable_vtxos(&arkoor.change).await
270 .context("error storing change VTXOs from preparatory arkoor")?;
271 self.store_locked_vtxos(&arkoor.created, None).await
272 .context("error storing new VTXOs (locked) from preparatory arkoor")?;
273 self.mark_vtxos_as_spent(&arkoor.inputs).await
274 .context("error marking used input VTXOs as spent")?;
275
276 let mut movement = self.inner.movements.new_guarded_movement_with_update(
277 Subsystem::OFFBOARD,
278 OffboardMovement::SendOnchain.to_string(),
279 OnDropStatus::Failed,
280 MovementUpdate::new()
281 .intended_balance(-amount.to_signed()?)
282 .effective_balance(-required_amount.to_signed()?)
283 .fee(fee)
284 .consumed_vtxos(&arkoor.inputs)
285 .produced_vtxos(&arkoor.change)
286 .metadata([(
287 "offboard_vtxos".into(),
288 serde_json::to_value(
289 arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
290 ).expect("offboard_vtxos can serde"),
291 )])
292 .sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
293 ).await?;
294 let state = VtxoState::Locked {
295 holder: Some(crate::vtxo::VtxoLockHolder::Movement { id: movement.id() }),
296 };
297 self.set_vtxo_states(&arkoor.created, &state, &[]).await
298 .context("error setting movement id on locked VTXOs")?;
299
300 let vtxos = arkoor.created;
302
303 let req = OffboardRequest {
304 script_pubkey: destination_spk.clone(),
305 net_amount: amount,
306 deduct_fees_from_gross_amount: false,
307 fee_rate: offboard_feerate,
308 };
309 let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
310
311 let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
312 .context("error performing offboard")?;
313
314 movement.apply_update(MovementUpdate::new()
315 .metadata(OffboardMovement::metadata(&signed_offboard_tx))
316 ).await.context("error updating movement")?;
317
318 if self.inner.config.offboard_required_confirmations == 0 {
319 for vtxo in &vtxos {
321 self.inner.db.update_vtxo_state_checked(
322 vtxo.id(),
323 VtxoState::Spent,
324 &[crate::vtxo::VtxoStateKind::Locked],
325 ).await.context("error marking vtxo as spent")?;
326 }
327 movement.success().await
328 .context("error finishing movement")?;
329 } else {
330 let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
332 self.inner.db.store_pending_offboard(&PendingOffboard {
333 movement_id: movement.id(),
334 offboard_txid: signed_offboard_tx.compute_txid(),
335 offboard_tx: signed_offboard_tx.clone(),
336 vtxo_ids,
337 destination: destination.to_string(),
338 created_at: chrono::Local::now(),
339 }).await.context("error storing pending offboard")?;
340
341 movement.stop();
343 }
344
345 Ok(signed_offboard_tx.compute_txid())
346 }
347
348 async fn offboard(
349 &self,
350 vtxos: Vec<WalletVtxo>,
351 destination: bitcoin::Address,
352 ) -> anyhow::Result<Txid> {
353 let (mut srv, ark) = self.require_server().await?;
354 let offboard_feerate = srv.offboard_feerate().await?;
355 let tip = self.inner.chain.tip().await?;
356
357 let destination_spk = destination.script_pubkey();
358 let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
359 let fee = ark.fees.offboard.calculate(
360 &destination_spk,
361 vtxos_amount,
362 offboard_feerate,
363 vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
364 ).context("error calculating offboard fee")?;
365 let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
366 let vtxo_keys = {
367 let mut keys = Vec::with_capacity(vtxos.len());
368 for v in &vtxos {
369 keys.push(self.get_vtxo_key(v).await?);
370 }
371 keys
372 };
373
374 let req = OffboardRequest {
375 script_pubkey: destination_spk.clone(),
376 net_amount,
377 deduct_fees_from_gross_amount: true,
378 fee_rate: offboard_feerate,
379 };
380
381 let input_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
384 let full_inputs = self.inner.db.get_full_vtxos(&input_ids).await
385 .context("failed to hydrate offboard input vtxos")?;
386 let signed_offboard_tx = self.offboard_inner(&mut srv, &full_inputs, &vtxo_keys, &req).await
387 .context("error performing offboard")?;
388
389 let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
391 let effective_amt = -SignedAmount::try_from(vtxos_amount)
392 .expect("can't have this many vtxo sats");
393 let destination_str = destination.to_string();
394 let movement_id = self.inner.movements.new_movement_with_update(
395 Subsystem::OFFBOARD,
396 OffboardMovement::Offboard.to_string(),
397 MovementUpdate::new()
398 .intended_balance(effective_amt)
399 .effective_balance(effective_amt)
400 .fee(fee)
401 .consumed_vtxos(&vtxos)
402 .sent_to([MovementDestination::bitcoin(destination, net_amount)])
403 .metadata(OffboardMovement::metadata(&signed_offboard_tx)),
404 ).await?;
405
406 self.lock_vtxos(&vtxos, Some(crate::vtxo::VtxoLockHolder::Movement { id: movement_id })).await?;
407
408 if self.inner.config.offboard_required_confirmations == 0 {
409 for vtxo in &vtxos {
411 self.inner.db.update_vtxo_state_checked(
412 vtxo.vtxo_id(),
413 VtxoState::Spent,
414 &[crate::vtxo::VtxoStateKind::Locked],
415 ).await.context("error marking vtxo as spent")?;
416 }
417 self.inner.movements.finish_movement(
418 movement_id,
419 MovementStatus::Successful,
420 ).await.context("error finishing movement")?;
421 } else {
422 self.inner.db.store_pending_offboard(&PendingOffboard {
424 movement_id,
425 offboard_txid: signed_offboard_tx.compute_txid(),
426 offboard_tx: signed_offboard_tx.clone(),
427 vtxo_ids,
428 destination: destination_str,
429 created_at: chrono::Local::now(),
430 }).await.context("error storing pending offboard")?
431 }
432
433 Ok(signed_offboard_tx.compute_txid())
434 }
435
436 pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
438 let input_vtxos = self.spendable_vtxos().await?;
439 Ok(self.offboard(input_vtxos, address).await?)
440 }
441
442 pub async fn offboard_vtxos<V: VtxoRef>(
444 &self,
445 vtxos: impl IntoIterator<Item = V>,
446 address: bitcoin::Address,
447 ) -> anyhow::Result<Txid> {
448 let mut input_vtxos = vec![];
449 for v in vtxos {
450 let id = v.vtxo_id();
451 let vtxo = match self.inner.db.get_wallet_vtxo(id).await? {
452 Some(vtxo) => vtxo,
453 _ => bail!("cannot find requested vtxo: {}", id),
454 };
455 input_vtxos.push(vtxo);
456 }
457
458 Ok(self.offboard(input_vtxos, address).await?)
459 }
460}