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 (change_keypair, change_key_index) = self.peek_next_keypair().await
269 .context("failed to derive arkoor change keypair")?;
270 let arkoor = self.create_checkpointed_arkoor_with_vtxos(
271 offboard_dest, vtxos.into_iter(), change_keypair,
272 ).await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
273 if !arkoor.change.is_empty() {
274 self.inner.db.store_vtxo_key(change_key_index, change_keypair.public_key()).await
275 .context("failed to store arkoor change keypair")?;
276 }
277
278 self.store_spendable_vtxos(&arkoor.change).await
279 .context("error storing change VTXOs from preparatory arkoor")?;
280 self.store_locked_vtxos(&arkoor.created, None).await
281 .context("error storing new VTXOs (locked) from preparatory arkoor")?;
282 self.mark_vtxos_as_spent(&arkoor.inputs).await
283 .context("error marking used input VTXOs as spent")?;
284
285 let mut movement = self.inner.movements.new_guarded_movement_with_update(
286 Subsystem::OFFBOARD,
287 OffboardMovement::SendOnchain.to_string(),
288 OnDropStatus::Failed,
289 MovementUpdate::new()
290 .intended_balance(-amount.to_signed()?)
291 .effective_balance(-required_amount.to_signed()?)
292 .fee(fee)
293 .consumed_vtxos(&arkoor.inputs)
294 .produced_vtxos(&arkoor.change)
295 .metadata([(
296 "offboard_vtxos".into(),
297 serde_json::to_value(
298 arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
299 ).expect("offboard_vtxos can serde"),
300 )])
301 .sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
302 ).await?;
303 let state = VtxoState::Locked {
304 holder: Some(crate::vtxo::VtxoLockHolder::Movement { id: movement.id() }),
305 };
306 self.set_vtxo_states(&arkoor.created, &state, &[]).await
307 .context("error setting movement id on locked VTXOs")?;
308
309 let vtxos = arkoor.created;
311
312 let req = OffboardRequest {
313 script_pubkey: destination_spk.clone(),
314 net_amount: amount,
315 deduct_fees_from_gross_amount: false,
316 fee_rate: offboard_feerate,
317 };
318 let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
319
320 let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
321 .context("error performing offboard")?;
322
323 movement.apply_update(MovementUpdate::new()
324 .metadata(OffboardMovement::metadata(&signed_offboard_tx))
325 ).await.context("error updating movement")?;
326
327 if self.inner.config.offboard_required_confirmations == 0 {
328 for vtxo in &vtxos {
330 self.inner.db.update_vtxo_state_checked(
331 vtxo.id(),
332 VtxoState::Spent,
333 &[crate::vtxo::VtxoStateKind::Locked],
334 ).await.context("error marking vtxo as spent")?;
335 }
336 movement.success().await
337 .context("error finishing movement")?;
338 } else {
339 let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
341 self.inner.db.store_pending_offboard(&PendingOffboard {
342 movement_id: movement.id(),
343 offboard_txid: signed_offboard_tx.compute_txid(),
344 offboard_tx: signed_offboard_tx.clone(),
345 vtxo_ids,
346 destination: destination.to_string(),
347 created_at: chrono::Local::now(),
348 }).await.context("error storing pending offboard")?;
349
350 movement.stop();
352 }
353
354 Ok(signed_offboard_tx.compute_txid())
355 }
356
357 async fn offboard(
358 &self,
359 vtxos: Vec<WalletVtxo>,
360 destination: bitcoin::Address,
361 ) -> anyhow::Result<Txid> {
362 let (mut srv, ark) = self.require_server().await?;
363 let offboard_feerate = srv.offboard_feerate().await?;
364 let tip = self.inner.chain.tip().await?;
365
366 let destination_spk = destination.script_pubkey();
367 let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
368 let fee = ark.fees.offboard.calculate(
369 &destination_spk,
370 vtxos_amount,
371 offboard_feerate,
372 vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
373 ).context("error calculating offboard fee")?;
374 let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
375 let vtxo_keys = {
376 let mut keys = Vec::with_capacity(vtxos.len());
377 for v in &vtxos {
378 keys.push(self.get_vtxo_key(v).await?);
379 }
380 keys
381 };
382
383 let req = OffboardRequest {
384 script_pubkey: destination_spk.clone(),
385 net_amount,
386 deduct_fees_from_gross_amount: true,
387 fee_rate: offboard_feerate,
388 };
389
390 let input_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
393 let full_inputs = self.inner.db.get_full_vtxos(&input_ids).await
394 .context("failed to hydrate offboard input vtxos")?;
395 let signed_offboard_tx = self.offboard_inner(&mut srv, &full_inputs, &vtxo_keys, &req).await
396 .context("error performing offboard")?;
397
398 let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
400 let effective_amt = -SignedAmount::try_from(vtxos_amount)
401 .expect("can't have this many vtxo sats");
402 let destination_str = destination.to_string();
403 let movement_id = self.inner.movements.new_movement_with_update(
404 Subsystem::OFFBOARD,
405 OffboardMovement::Offboard.to_string(),
406 MovementUpdate::new()
407 .intended_balance(effective_amt)
408 .effective_balance(effective_amt)
409 .fee(fee)
410 .consumed_vtxos(&vtxos)
411 .sent_to([MovementDestination::bitcoin(destination, net_amount)])
412 .metadata(OffboardMovement::metadata(&signed_offboard_tx)),
413 ).await?;
414
415 self.lock_vtxos(&vtxos, Some(crate::vtxo::VtxoLockHolder::Movement { id: movement_id })).await?;
416
417 if self.inner.config.offboard_required_confirmations == 0 {
418 for vtxo in &vtxos {
420 self.inner.db.update_vtxo_state_checked(
421 vtxo.vtxo_id(),
422 VtxoState::Spent,
423 &[crate::vtxo::VtxoStateKind::Locked],
424 ).await.context("error marking vtxo as spent")?;
425 }
426 self.inner.movements.finish_movement(
427 movement_id,
428 MovementStatus::Successful,
429 ).await.context("error finishing movement")?;
430 } else {
431 self.inner.db.store_pending_offboard(&PendingOffboard {
433 movement_id,
434 offboard_txid: signed_offboard_tx.compute_txid(),
435 offboard_tx: signed_offboard_tx.clone(),
436 vtxo_ids,
437 destination: destination_str,
438 created_at: chrono::Local::now(),
439 }).await.context("error storing pending offboard")?
440 }
441
442 Ok(signed_offboard_tx.compute_txid())
443 }
444
445 pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
447 let input_vtxos = self.spendable_vtxos().await?;
448 Ok(self.offboard(input_vtxos, address).await?)
449 }
450
451 pub async fn offboard_vtxos<V: VtxoRef>(
453 &self,
454 vtxos: impl IntoIterator<Item = V>,
455 address: bitcoin::Address,
456 ) -> anyhow::Result<Txid> {
457 let mut input_vtxos = vec![];
458 for v in vtxos {
459 let id = v.vtxo_id();
460 let vtxo = match self.inner.db.get_wallet_vtxo(id).await? {
461 Some(vtxo) => vtxo,
462 _ => bail!("cannot find requested vtxo: {}", id),
463 };
464 input_vtxos.push(vtxo);
465 }
466
467 Ok(self.offboard(input_vtxos, address).await?)
468 }
469}