1use std::collections::HashSet;
29use std::iter;
30use std::time::Duration;
31
32use anyhow::Context;
33use bitcoin::consensus::encode::serialize_hex;
34use bitcoin::hex::DisplayHex;
35use bitcoin::{Amount, FeeRate, SignedAmount, Transaction, Txid};
36use bitcoin::hashes::Hash;
37use log::{error, info, trace, warn};
38
39use ark::{musig, ProtocolEncoding, VtxoPolicy, VtxoId, fees};
40use ark::arkoor::ArkoorDestination;
41use ark::attestations::OffboardRequestAttestation;
42use ark::fees::VtxoFeeInfo;
43use ark::offboard::{OffboardForfeitContext, OffboardRequest};
44use ark::vtxo::VtxoRef;
45use bitcoin_ext::{BlockHeight, TxStatus};
46use server_rpc::{protos, TryFromBytes};
47
48use crate::{Wallet, WalletVtxo};
49use crate::actions::{Advance, AdvanceError, WalletAction, WalletActionId, BASE_RETRY_BACKOFF};
50use crate::movement::update::MovementUpdate;
51use crate::movement::{MovementDestination, MovementId, MovementStatus};
52use crate::subsystem::{OffboardMovement, Subsystem};
53use crate::vtxo::{VtxoLockHolder, VtxoState, VtxoStateKind};
54use crate::vtxo::selection::InputSelection;
55
56pub(crate) const CONFIRMATION_POLL_INTERVAL: Duration = Duration::from_secs(30);
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Offboard {
64 pub id: WalletActionId,
66 pub destination: bitcoin::Address<bitcoin::address::NetworkUnchecked>,
67 #[serde(with = "bitcoin::amount::serde::as_sat")]
68 pub onchain_output_amount: Amount,
69 #[serde(with = "bitcoin::amount::serde::as_sat")]
70 pub committed_fee: Amount,
71 pub committed_fee_rate: FeeRate,
72 pub kind: OffboardKind,
73
74 pub progress: Progress,
76}
77
78impl Offboard {
79 pub fn id(&self) -> WalletActionId {
80 self.id.clone()
81 }
82
83 pub fn check_destination(&self, network: bitcoin::Network) -> anyhow::Result<bitcoin::Address> {
84 Ok(self.destination.clone().require_network(network)?)
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub enum OffboardKind {
99 OffboardWhole {
101 input_vtxo_ids: Vec<VtxoId>,
102 },
103 SendOnchain {
105 input_vtxo_ids: Vec<VtxoId>,
106 arkoor_key_index: u32,
108 change_key_index: u32,
111 },
112}
113
114impl OffboardKind {
115 fn deduct_fees_from_gross_amount(&self) -> bool {
116 match self {
117 OffboardKind::OffboardWhole { .. } => true,
118 OffboardKind::SendOnchain { .. } => false,
119 }
120 }
121
122 fn vtxo_ids(&self) -> &Vec<VtxoId> {
123 match self {
124 OffboardKind::OffboardWhole { input_vtxo_ids } => input_vtxo_ids,
125 OffboardKind::SendOnchain { input_vtxo_ids, .. } => input_vtxo_ids,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub enum Progress {
137 Start,
139 SplitWithArkoor,
142 ArkoorRegistrationRequired {
146 offboard_vtxo_ids: Vec<VtxoId>,
147 change_vtxo_ids: Vec<VtxoId>,
148 },
149 ReadyForOffboard {
151 offboard_vtxo_ids: Vec<VtxoId>,
154 #[serde(default)]
161 prior_txid: Option<Txid>,
162 },
163 OffboardTxPrepared {
168 offboard_vtxo_ids: Vec<VtxoId>,
169 #[serde(with = "bitcoin_ext::serde::encodable")]
170 offboard_tx: Transaction,
171 forfeit_cosign_nonces: Vec<musig::PublicNonce>,
174 movement_id: MovementId,
175 },
176 ReadyForBroadcast {
177 offboard_vtxo_ids: Vec<VtxoId>,
178 #[serde(with = "bitcoin_ext::serde::encodable")]
179 signed_offboard_tx: Transaction,
180 movement_id: MovementId,
181 },
182 AwaitingConfirmations {
184 offboard_vtxo_ids: Vec<VtxoId>,
185 offboard_txid: Txid,
186 #[serde(with = "bitcoin_ext::serde::encodable")]
187 offboard_tx: Transaction,
188 movement_id: MovementId,
189 created_at: chrono::DateTime<chrono::Utc>,
190 },
191}
192
193pub(crate) enum ConfirmationOutcome {
195 Confirmed,
196 Pending,
197 Lost,
205}
206
207pub enum StartOffboardSpec {
210 OffboardWhole { vtxos: Vec<WalletVtxo> },
213 SendOnchain { amount: Amount },
216}
217
218#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
219#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
220impl WalletAction for Offboard {
221 fn id(&self) -> WalletActionId { Offboard::id(self) }
222
223 async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError> {
224 let new_progress = match self.progress.clone() {
225 Progress::Start => {
226 lock_vtxos(wallet, &self).await?
227 },
228 Progress::SplitWithArkoor => {
229 arkoor_split_offboard(wallet, &self).await?
230 }
231 Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, change_vtxo_ids } => {
232 register_arkoor_split(wallet, offboard_vtxo_ids, change_vtxo_ids).await?
233 },
234 Progress::ReadyForOffboard { offboard_vtxo_ids, .. } => {
235 prepare_offboard(wallet, &self, offboard_vtxo_ids).await?
236 },
237 Progress::OffboardTxPrepared {
238 offboard_vtxo_ids, offboard_tx, forfeit_cosign_nonces, movement_id
239 } => {
240 finish_offboard(
241 wallet, offboard_vtxo_ids, offboard_tx, forfeit_cosign_nonces, movement_id,
242 ).await?
243 },
244 Progress::ReadyForBroadcast { offboard_vtxo_ids, signed_offboard_tx, movement_id } => {
245 let progress = broadcast_offboard(
250 wallet, offboard_vtxo_ids, signed_offboard_tx, movement_id,
251 ).await?;
252 return Ok(Advance::Park {
253 state: Offboard { progress, ..self },
254 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
255 error: None,
256 });
257 },
258 Progress::AwaitingConfirmations {
259 ref offboard_vtxo_ids, offboard_txid, offboard_tx, movement_id, created_at,
260 } => {
261 return match check_offboard_confirmation(
262 wallet, &offboard_tx, created_at,
263 ).await? {
264 ConfirmationOutcome::Confirmed => {
265 settle_offboard(
266 wallet, offboard_vtxo_ids, movement_id, offboard_txid,
267 ).await?;
268 Ok(Advance::Done)
269 },
270 ConfirmationOutcome::Pending => {
271 Ok(Advance::Park {
272 state: self,
273 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
274 error: None,
275 })
276 },
277 ConfirmationOutcome::Lost => {
278 let error = anyhow!(
283 "offboard tx {} has not been seen on chain since {}; \
284 the server can still commit the signed tx, making the \
285 forfeits valid, so the inputs stay locked; \
286 will keep checking, but manual intervention may be needed",
287 offboard_txid, created_at,
288 );
289 error!("{:#}", error);
290 Ok(Advance::Park {
291 state: self,
292 wake_after: None,
293 error: Some(error.into()),
294 })
295 },
296 }
297 },
298 };
299
300 Ok(Advance::Next(Offboard { progress: new_progress, ..self }))
301 }
302
303 async fn on_retry(
304 self,
305 _wallet: &Wallet,
306 attempts: u32,
307 err: AdvanceError,
308 ) -> anyhow::Result<Advance<Self>> {
309 match self.progress {
310 Progress::Start => {
311 let error = anyhow::Error::from(err).context("Unable to lock VTXOs");
312 return Ok(Advance::Failed(error));
313 },
314 Progress::SplitWithArkoor |
315 Progress::ArkoorRegistrationRequired { .. } |
316 Progress::ReadyForOffboard { .. } |
317 Progress::OffboardTxPrepared { .. } |
318 Progress::ReadyForBroadcast { .. } |
319 Progress::AwaitingConfirmations { .. } => {},
320 }
321 let delay = attempts.pow(2) * BASE_RETRY_BACKOFF;
327 Ok(Advance::Park { state: self, wake_after: Some(delay), error: Some(err) })
328 }
329
330 async fn on_rejection(
331 self,
332 wallet: &Wallet,
333 error: AdvanceError,
334 ) -> anyhow::Result<Advance<Self>> {
335 match &self.progress {
336 Progress::Start | Progress::AwaitingConfirmations { .. } => {
337 debug_assert!(false, "server cannot reject here");
338 error!("Rejection should be impossible here: {:#}", error);
339 Ok(Advance::Park {
340 state: self.clone(),
341 wake_after: None,
342 error: Some(error.into())
343 })
344 },
345 Progress::SplitWithArkoor => {
346 fail_offboard_movement(wallet, &self).await?;
348 Ok(Advance::Failed(error.into()))
349 }
350 Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, .. } |
351 Progress::ReadyForBroadcast { offboard_vtxo_ids, .. } => {
352 error!("Server rejected VTXOs, consider exiting: {:?}", offboard_vtxo_ids);
354 Ok(Advance::Park {
355 state: self.clone(),
356 wake_after: None,
357 error: Some(error.into())
358 })
359 },
360 Progress::ReadyForOffboard { offboard_vtxo_ids, prior_txid: Some(prior_txid) } => {
361 if let Some(progress) = adopt_broadcast_offboard(
365 wallet, &self, offboard_vtxo_ids, *prior_txid,
366 ).await? {
367 return Ok(Advance::Next(Offboard { progress, ..self.clone() }));
368 }
369 if rejection_proves_inputs_spendable(&error) {
370 warn!("Offboard prepare rejected after session loss, cancelling: {:#}", error);
377 fail_offboard_movement(wallet, &self).await?;
378 return Ok(Advance::Failed(error.into()));
379 }
380 error!("Offboard inputs rejected but prior offboard tx {} is not on chain, \
386 will keep looking for it: {:#}", prior_txid, error);
387 Ok(Advance::Park {
388 state: self.clone(),
389 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
390 error: Some(error.into()),
391 })
392 },
393 Progress::ReadyForOffboard { prior_txid: None, .. } => {
394 fail_offboard_movement(wallet, &self).await?;
396 Ok(Advance::Failed(error.into()))
397 },
398 Progress::OffboardTxPrepared { offboard_vtxo_ids, offboard_tx, .. } => {
399 let offboard_txid = offboard_tx.compute_txid();
407 if let Some(progress) = adopt_broadcast_offboard(
408 wallet, &self, offboard_vtxo_ids, offboard_txid,
409 ).await? {
410 return Ok(Advance::Next(Offboard { progress, ..self.clone() }));
411 }
412 warn!("Offboard session for tx {} is gone and the tx is not on chain, \
413 going back to prepare a fresh session: {:#}", offboard_txid, error);
414 let state = Offboard {
415 progress: Progress::ReadyForOffboard {
416 offboard_vtxo_ids: offboard_vtxo_ids.clone(),
417 prior_txid: Some(offboard_txid),
418 },
419 ..self.clone()
420 };
421 Ok(Advance::Park {
425 state,
426 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
427 error: None,
428 })
429 },
430 }
431 }
432}
433
434pub(crate) async fn start_offboard(
442 wallet: &Wallet,
443 destination: bitcoin::Address,
444 spec: StartOffboardSpec,
445) -> anyhow::Result<Offboard> {
446 let (srv, ark) = wallet.require_server().await?;
447 let offboard_feerate = srv.offboard_feerate().await?;
448 let tip = wallet.inner.chain.tip().await?;
449 let destination_spk = destination.script_pubkey();
450 let dust = destination_spk.minimal_non_dust();
451 let id = {
452 let bytes: [u8; 16] = rand::random();
453 bytes.as_hex().to_string()
454 };
455
456 let (net_amount, fee, kind) = match spec {
457 StartOffboardSpec::OffboardWhole { vtxos } => {
458 if vtxos.len() > srv.ark_info().await.max_offboard_inputs {
459 bail!(
460 "max inputs for offboard is {}, {} were provided",
461 srv.ark_info().await.max_offboard_inputs, vtxos.len(),
462 );
463 }
464 let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
465 let fee = ark.fees.offboard.calculate(
466 &destination_spk, vtxos_amount, offboard_feerate,
467 vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
468 ).context("error calculating offboard fee")?;
469 let net_amount = fees::validate_and_subtract_fee_min_dust(vtxos_amount, fee, dust)
470 .context("offboard fee leaves dust")?;
471
472 (net_amount, fee, OffboardKind::OffboardWhole {
473 input_vtxo_ids: vtxos.iter().map(|v| v.id()).collect(),
474 })
475 },
476 StartOffboardSpec::SendOnchain { amount } => {
477 if amount < dust {
478 bail!("the minimum you can send to {} is {}", destination, dust);
479 }
480 let (vtxos, fee) = InputSelection::new()
481 .max_inputs(srv.ark_info().await.max_offboard_inputs)
482 .fee_scheme(wallet.chain().tip().await?, |a, v| {
483 ark.fees.offboard.calculate(&destination_spk, a, offboard_feerate, v)
484 .ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
485 })
486 .select(wallet.spendable_vtxos().await?, amount)?;
487
488 let (_, arkoor_key_index) = wallet.derive_store_next_keypair().await
489 .context("failed to create new keypair")?;
490 let (_, change_key_index) = wallet.derive_store_next_keypair().await
491 .context("failed to create new change keypair")?;
492
493 (amount, fee, OffboardKind::SendOnchain {
494 input_vtxo_ids: vtxos.iter().map(|v| v.id()).collect(),
495 arkoor_key_index,
496 change_key_index,
497 })
498 },
499 };
500
501 let input_vtxo_ids_len = kind.vtxo_ids().len();
504 let unique = kind.vtxo_ids().iter().collect::<HashSet<_>>();
505 if input_vtxo_ids_len != unique.len() {
506 bail!("offboard inputs must not contain duplicates");
507 }
508
509 Ok(Offboard {
510 id,
511 kind,
512 destination: destination.into_unchecked(),
513 onchain_output_amount: net_amount,
514 committed_fee: fee,
515 committed_fee_rate: offboard_feerate,
516 progress: Progress::Start,
517 })
518}
519
520async fn lock_vtxos(
522 wallet: &Wallet,
523 action: &Offboard,
524) -> Result<Progress, AdvanceError> {
525 wallet.lock_vtxos(
526 action.kind.vtxo_ids(),
527 Some(VtxoLockHolder::Action { id: action.id.clone() }),
528 ).await?;
529 match &action.kind {
530 OffboardKind::OffboardWhole { input_vtxo_ids } => {
531 Ok(Progress::ReadyForOffboard {
532 offboard_vtxo_ids: input_vtxo_ids.clone(),
533 prior_txid: None,
534 })
535 },
536 OffboardKind::SendOnchain { .. } => {
537 Ok(Progress::SplitWithArkoor)
538 },
539 }
540}
541
542async fn arkoor_split_offboard(
545 wallet: &Wallet,
546 action: &Offboard,
547) -> Result<Progress, AdvanceError> {
548 let OffboardKind::SendOnchain {
549 input_vtxo_ids, arkoor_key_index, change_key_index,
550 } = &action.kind
551 else {
552 return Err(anyhow!("arkoor_split_offboard called for non-SendOnchain kind").into());
553 };
554
555 let mut inputs = Vec::with_capacity(input_vtxo_ids.len());
556 for id in input_vtxo_ids {
557 inputs.push(wallet.get_vtxo_by_id(*id).await
558 .context("failed to load offboard input vtxo")?);
559 }
560
561 let required_amount = action.onchain_output_amount + action.committed_fee;
563 let keypair = wallet.peek_keypair(*arkoor_key_index).await
564 .context("failed to load keypair for offboard action")?;
565 let change_keypair = wallet.peek_keypair(*change_key_index).await
566 .context("failed to load change keypair for offboard action")?;
567 let split_destination = ArkoorDestination {
568 total_amount: required_amount,
569 policy: VtxoPolicy::new_pubkey(keypair.public_key()),
570 };
571 let arkoor = wallet
572 .create_checkpointed_arkoor_with_vtxos(split_destination, inputs.into_iter(), change_keypair)
573 .await
574 .context("error preparing offboard vtxos with arkoor")?;
575
576 wallet.store_locked_vtxos(
582 &arkoor.change,
583 Some(VtxoLockHolder::Action { id: action.id.clone() }),
584 ).await.context("error storing change vtxos from preparatory arkoor")?;
585 wallet.store_locked_vtxos(
586 &arkoor.created,
587 Some(VtxoLockHolder::Action { id: action.id.clone() }),
588 ).await.context("error storing offboard vtxos from preparatory arkoor")?;
589 wallet.mark_vtxos_as_spent(&arkoor.inputs).await
590 .context("error marking offboard inputs as spent")?;
591
592 let offboard_vtxo_ids = arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>();
594 let change_vtxo_ids = arkoor.change.iter().map(|v| v.id()).collect::<Vec<_>>();
595 get_or_create_movement(
596 wallet, action, &offboard_vtxo_ids, change_vtxo_ids.iter().copied(),
597 ).await?;
598
599 Ok(Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, change_vtxo_ids })
600}
601
602async fn register_arkoor_split(
606 wallet: &Wallet,
607 offboard_vtxo_ids: Vec<VtxoId>,
608 change_vtxo_ids: Vec<VtxoId>,
609) -> Result<Progress, AdvanceError> {
610 let to_register = offboard_vtxo_ids.iter().chain(&change_vtxo_ids).copied().collect::<Vec<_>>();
611 let full_vtxos = wallet.inner.db.get_full_vtxos(&to_register).await
612 .context("failed to hydrate arkoor split vtxos")?;
613
614 wallet.register_vtxo_transactions_with_server(&full_vtxos).await
615 .context("failed to register arkoor split vtxo transactions with server")?;
616
617 wallet.unlock_vtxos(&change_vtxo_ids).await
619 .context("failed to unlock change vtxos after registration")?;
620
621 Ok(Progress::ReadyForOffboard { offboard_vtxo_ids, prior_txid: None })
622}
623
624async fn prepare_offboard(
632 wallet: &Wallet,
633 action: &Offboard,
634 mut offboard_vtxo_ids: Vec<VtxoId>,
635) -> Result<Progress, AdvanceError> {
636 let (mut srv, _) = wallet.require_server().await?;
637
638 offboard_vtxo_ids.sort_unstable();
640 debug_assert!(
641 offboard_vtxo_ids.windows(2).all(|w| w[0] != w[1]),
642 "offboard inputs must not contain duplicates",
643 );
644 let vtxos = wallet.inner.db.get_wallet_vtxos(&offboard_vtxo_ids).await
645 .context("failed to load offboard input vtxos")?;
646 debug_assert!(
647 vtxos.iter().map(|v| v.id()).eq(offboard_vtxo_ids.iter().copied()),
648 "get_wallet_vtxos should return inputs in the exact same order",
649 );
650
651 let destination = action.check_destination(wallet.network().await?)?;
656 let destination_spk = destination.script_pubkey();
657 let req = OffboardRequest {
658 script_pubkey: destination_spk,
659 net_amount: action.onchain_output_amount,
660 deduct_fees_from_gross_amount: action.kind.deduct_fees_from_gross_amount(),
661 fee_rate: action.committed_fee_rate,
662 };
663 let attestation = {
664 let mut attestations = Vec::with_capacity(vtxos.len());
665 for v in &vtxos {
666 let key = wallet.get_vtxo_key(v).await?;
667 let att = OffboardRequestAttestation::new(&req, &offboard_vtxo_ids, &key).serialize();
668 attestations.push(att);
669 }
670 attestations
671 };
672
673 let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
676 offboard: Some(req.clone().into()),
677 input_vtxo_ids: offboard_vtxo_ids.iter()
678 .map(|id| id.to_bytes().to_vec())
679 .collect(),
680 attestation,
681 }).await.map_err(AdvanceError::Server)?.into_inner();
682
683 let unsigned_tx = bitcoin::consensus::deserialize::<Transaction>(&prep_resp.offboard_tx)
684 .with_context(|| format!("received invalid unsigned offboard tx from server: {}",
685 prep_resp.offboard_tx.as_hex(),
686 ))?;
687 let offboard_txid = unsigned_tx.compute_txid();
688 let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_tx);
689 ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
690 info!("Received unsigned offboard tx {} from server", offboard_txid);
691
692 let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
695 musig::PublicNonce::from_bytes(&n)
696 .context("received invalid public cosign nonce from server")
697 }).collect::<anyhow::Result<Vec<_>>>()?;
698
699 let movement_id = get_or_create_movement(
702 wallet, action, &offboard_vtxo_ids, iter::empty::<VtxoId>(),
703 ).await?;
704 Ok(Progress::OffboardTxPrepared {
705 offboard_vtxo_ids,
706 offboard_tx: unsigned_tx,
707 forfeit_cosign_nonces,
708 movement_id,
709 })
710}
711
712async fn finish_offboard(
725 wallet: &Wallet,
726 offboard_vtxo_ids: Vec<VtxoId>,
727 offboard_tx: Transaction,
728 server_forfeit_cosign_nonces: Vec<musig::PublicNonce>,
729 movement_id: MovementId,
730) -> Result<Progress, AdvanceError> {
731 let (mut srv, _) = wallet.require_server().await?;
732
733 let full_inputs = wallet.inner.db.get_full_vtxos(&offboard_vtxo_ids).await
734 .context("failed to hydrate offboard input vtxos")?;
735 debug_assert!(
736 full_inputs.iter().map(|v| v.id()).eq(offboard_vtxo_ids.iter().copied()),
737 "get_full_vtxos should return inputs in the exact same order",
738 );
739 let mut vtxo_keys = Vec::with_capacity(full_inputs.len());
740 for v in &full_inputs {
741 vtxo_keys.push(wallet.get_vtxo_key(v).await?);
742 }
743 let ctx = OffboardForfeitContext::new(&full_inputs, &offboard_tx);
744 let sigs = ctx.user_sign_forfeits(&vtxo_keys, &server_forfeit_cosign_nonces);
745
746 let offboard_txid = offboard_tx.compute_txid();
747 let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
748 offboard_txid: offboard_txid.as_byte_array().to_vec(),
749 user_nonces: sigs.public_nonces.iter()
750 .map(|n| n.serialize().to_vec())
751 .collect(),
752 partial_signatures: sigs.partial_signatures.iter()
753 .map(|s| s.serialize().to_vec())
754 .collect(),
755 }).await.map_err(AdvanceError::Server)?.into_inner();
756
757 let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
758 &finish_resp.signed_offboard_tx,
759 ).with_context(|| format!(
760 "received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
761 ))?;
762 if signed_offboard_tx.compute_txid() != offboard_txid {
763 return Err(anyhow!("Signed offboard tx received from server is different from \
764 unsigned tx we forfeited for: unsigned={}, signed={}",
765 serialize_hex(&offboard_tx), finish_resp.signed_offboard_tx.as_hex(),
766 ).into());
767 }
768 if signed_offboard_tx.input.iter().any(|i| i.witness.is_empty() && i.script_sig.is_empty()) {
773 return Err(anyhow!("Signed offboard tx received from server has an unsigned input: {}",
774 finish_resp.signed_offboard_tx.as_hex(),
775 ).into());
776 }
777
778 wallet.inner.movements.update_movement(
779 movement_id,
780 MovementUpdate::new().metadata(OffboardMovement::metadata(&signed_offboard_tx)),
781 ).await.context("failed to update movement with offboard tx")?;
782
783 Ok(Progress::ReadyForBroadcast { offboard_vtxo_ids, signed_offboard_tx, movement_id })
784}
785
786fn rejection_proves_inputs_spendable(error: &AdvanceError) -> bool {
795 let AdvanceError::Server(status) = error else {
796 return false;
797 };
798 let msg = status.message();
800 msg.contains("fee rate is no longer valid")
801 || msg.contains("does not match expected amount")
802 || msg.contains("output address is blocked")
803}
804
805async fn adopt_broadcast_offboard(
813 wallet: &Wallet,
814 action: &Offboard,
815 offboard_vtxo_ids: &Vec<VtxoId>,
816 offboard_txid: Txid,
817) -> anyhow::Result<Option<Progress>> {
818 let tx = wallet.inner.chain.get_tx(&offboard_txid).await
819 .with_context(|| format!("failed to look up offboard tx {} on chain", offboard_txid))?;
820 let Some(offboard_tx) = tx else {
821 return Ok(None);
822 };
823
824 info!("Found offboard tx {} on chain, adopting it", offboard_txid);
825 let movement_id = get_or_create_movement(
826 wallet, action, offboard_vtxo_ids, iter::empty::<VtxoId>(),
827 ).await?;
828 Ok(Some(Progress::AwaitingConfirmations {
829 offboard_vtxo_ids: offboard_vtxo_ids.to_vec(),
830 offboard_txid,
831 offboard_tx,
832 movement_id,
833 created_at: chrono::Utc::now(),
834 }))
835}
836
837async fn broadcast_offboard(
840 wallet: &Wallet,
841 offboard_vtxo_ids: Vec<VtxoId>,
842 offboard_tx: Transaction,
843 movement_id: MovementId,
844) -> Result<Progress, AdvanceError> {
845 let offboard_txid = offboard_tx.compute_txid();
846 wallet.inner.chain.broadcast_tx(&offboard_tx).await.with_context(|| format!(
847 "error broadcasting offboard tx {}", offboard_txid,
848 ))?;
849 Ok(Progress::AwaitingConfirmations {
850 offboard_vtxo_ids,
851 offboard_txid,
852 offboard_tx,
853 movement_id,
854 created_at: chrono::Utc::now(),
855 })
856}
857
858async fn settle_offboard(
863 wallet: &Wallet,
864 offboard_vtxo_ids: &[VtxoId],
865 movement_id: MovementId,
866 offboard_txid: Txid,
867) -> anyhow::Result<()> {
868 info!("Offboard tx {} confirmed, finalizing movement {}",
869 offboard_txid, movement_id);
870
871 wallet.inner.db.update_vtxo_states_checked(
877 offboard_vtxo_ids,
878 VtxoState::Spent,
879 &[VtxoStateKind::Locked, VtxoStateKind::Spent],
880 ).await.context("failed to mark offboard vtxos as spent")?;
881
882 wallet.inner.movements.finish_movement(movement_id, MovementStatus::Successful).await
883 .context("failed to finish offboard movement")?;
884 Ok(())
885}
886
887async fn check_offboard_confirmation(
890 wallet: &Wallet,
891 offboard_tx: &Transaction,
892 created_at: chrono::DateTime<chrono::Utc>,
893) -> anyhow::Result<ConfirmationOutcome> {
894 let offboard_txid = offboard_tx.compute_txid();
895 let required_confs = wallet.inner.config.offboard_required_confirmations;
896 let current_height = wallet.inner.chain.tip().await
897 .context("error fetching chain tip")?;
898 let status = wallet.inner.chain.tx_status(offboard_txid).await;
899
900 match status {
901 Ok(TxStatus::Confirmed(block_ref)) => {
902 let confs = current_height - (block_ref.height - 1);
903 if confs >= required_confs as BlockHeight {
904 Ok(ConfirmationOutcome::Confirmed)
905 } else {
906 trace!(
907 "Offboard tx {} has {}/{} confirmations, waiting...",
908 offboard_txid, confs, required_confs,
909 );
910 Ok(ConfirmationOutcome::Pending)
911 }
912 },
913 Ok(TxStatus::Mempool) => {
914 if required_confs == 0 {
915 Ok(ConfirmationOutcome::Confirmed)
916 } else {
917 trace!("Offboard tx {} still in mempool, waiting...", offboard_txid);
918 Ok(ConfirmationOutcome::Pending)
919 }
920 },
921 Ok(TxStatus::NotFound) => {
922 let age = chrono::Utc::now() - created_at;
923 let grace_period = chrono::Duration::seconds(
924 wallet.inner.config.offboard_lost_tx_grace_period_secs as i64,
925 );
926 if age > grace_period {
927 return Ok(ConfirmationOutcome::Lost);
928 }
929 trace!("Offboard tx {} not found — re-broadcasting...", offboard_txid);
930 wallet.inner.chain.broadcast_tx(&offboard_tx).await.with_context(|| format!(
931 "error broadcasting offboard tx {}", offboard_txid,
932 ))?;
933 Ok(ConfirmationOutcome::Pending)
934 },
935 Err(e) => {
936 warn!("Failed to check status of offboard tx {}: {:#}", offboard_txid, e);
937 Ok(ConfirmationOutcome::Pending)
938 },
939 }
940}
941
942async fn get_or_create_movement(
944 wallet: &Wallet,
945 action: &Offboard,
946 offboard_vtxo_ids: &Vec<VtxoId>,
947 change: impl IntoIterator<Item = impl VtxoRef>,
948) -> anyhow::Result<MovementId> {
949 let destination = action.check_destination(wallet.network().await?)?;
950 let net = action.onchain_output_amount;
951 let required = net.checked_add(action.committed_fee).context("overflow")?;
952 match &action.kind {
953 OffboardKind::OffboardWhole { .. } => {
954 let effective_amt = -SignedAmount::try_from(required)
955 .context("can't have this many vtxo sats")?;
956 wallet.inner.movements.get_or_create_movement_with_action(
957 Subsystem::OFFBOARD,
958 OffboardMovement::Offboard.to_string(),
959 &action.id,
960 MovementUpdate::new()
961 .intended_balance(effective_amt)
962 .effective_balance(effective_amt)
963 .fee(action.committed_fee)
964 .consumed_vtxos(offboard_vtxo_ids)
965 .sent_to([MovementDestination::bitcoin(destination, net)]),
966 ).await.context("failed to create offboard movement")
967 },
968 OffboardKind::SendOnchain { input_vtxo_ids, .. } => {
969 wallet.inner.movements.get_or_create_movement_with_action(
970 Subsystem::OFFBOARD,
971 OffboardMovement::SendOnchain.to_string(),
972 &action.id,
973 MovementUpdate::new()
974 .intended_balance(-net.to_signed().context("amount out of range")?)
975 .effective_balance(-required.to_signed().context("required amount out of range")?)
976 .fee(action.committed_fee)
977 .consumed_vtxos(input_vtxo_ids)
978 .produced_vtxos(change)
979 .metadata([(
980 "offboard_vtxos".into(),
981 serde_json::to_value(offboard_vtxo_ids).expect("offboard_vtxos can serde"),
982 )])
983 .sent_to([MovementDestination::bitcoin(destination, net)]),
984 ).await.context("failed to create send-onchain movement")
985 }
986 }
987}
988
989async fn fail_offboard_movement(
998 wallet: &Wallet,
999 action: &Offboard,
1000) -> anyhow::Result<()> {
1001 let offboard_vtxo_ids = action.kind.vtxo_ids();
1002 let movement_id = get_or_create_movement(
1003 wallet, action, offboard_vtxo_ids, iter::empty::<VtxoId>(),
1004 ).await?;
1005 wallet.inner.movements.finish_movement_with_update(
1009 movement_id,
1010 MovementStatus::Failed,
1011 MovementUpdate::new().effective_balance(SignedAmount::ZERO),
1012 ).await.context("failed to mark offboard movement as failed")
1013}