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, OffboardForfeitError, 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::arkoor::split_change_amount;
51use crate::movement::update::MovementUpdate;
52use crate::movement::{MovementDestination, MovementId, MovementStatus};
53use crate::subsystem::{OffboardMovement, Subsystem};
54use crate::vtxo::{VtxoLockHolder, VtxoState, VtxoStateKind};
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 #[serde(default, with = "crate::utils::serde::opt_amount_vec_sat")]
114 change_pieces: Option<Vec<Amount>>,
115 },
116}
117
118impl OffboardKind {
119 fn deduct_fees_from_gross_amount(&self) -> bool {
120 match self {
121 OffboardKind::OffboardWhole { .. } => true,
122 OffboardKind::SendOnchain { .. } => false,
123 }
124 }
125
126 fn vtxo_ids(&self) -> &Vec<VtxoId> {
127 match self {
128 OffboardKind::OffboardWhole { input_vtxo_ids } => input_vtxo_ids,
129 OffboardKind::SendOnchain { input_vtxo_ids, .. } => input_vtxo_ids,
130 }
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub enum Progress {
141 Start,
143 SplitWithArkoor,
146 ArkoorRegistrationRequired {
150 offboard_vtxo_ids: Vec<VtxoId>,
151 change_vtxo_ids: Vec<VtxoId>,
152 },
153 ReadyForOffboard {
155 offboard_vtxo_ids: Vec<VtxoId>,
158 #[serde(default)]
165 prior_txid: Option<Txid>,
166 },
167 OffboardTxPrepared {
172 offboard_vtxo_ids: Vec<VtxoId>,
173 #[serde(with = "bitcoin_ext::serde::encodable")]
174 offboard_tx: Transaction,
175 forfeit_cosign_nonces: Vec<musig::PublicNonce>,
178 movement_id: MovementId,
179 },
180 ReadyForBroadcast {
181 offboard_vtxo_ids: Vec<VtxoId>,
182 #[serde(with = "bitcoin_ext::serde::encodable")]
183 signed_offboard_tx: Transaction,
184 movement_id: MovementId,
185 },
186 AwaitingConfirmations {
188 offboard_vtxo_ids: Vec<VtxoId>,
189 offboard_txid: Txid,
190 #[serde(with = "bitcoin_ext::serde::encodable")]
191 offboard_tx: Transaction,
192 movement_id: MovementId,
193 created_at: chrono::DateTime<chrono::Utc>,
194 },
195}
196
197pub(crate) enum ConfirmationOutcome {
199 Confirmed,
200 Pending,
201 Lost,
209}
210
211pub enum StartOffboardSpec {
214 OffboardWhole { vtxos: Vec<WalletVtxo> },
217 SendOnchain { amount: Amount },
220}
221
222#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
223#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
224impl WalletAction for Offboard {
225 fn id(&self) -> WalletActionId { Offboard::id(self) }
226
227 async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError> {
228 let new_progress = match self.progress.clone() {
229 Progress::Start => {
230 lock_vtxos(wallet, &self).await?
231 },
232 Progress::SplitWithArkoor => {
233 arkoor_split_offboard(wallet, &self).await?
234 }
235 Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, change_vtxo_ids } => {
236 register_arkoor_split(wallet, &self, offboard_vtxo_ids, change_vtxo_ids).await?
237 },
238 Progress::ReadyForOffboard { offboard_vtxo_ids, .. } => {
239 prepare_offboard(wallet, &self, offboard_vtxo_ids).await?
240 },
241 Progress::OffboardTxPrepared {
242 offboard_vtxo_ids, offboard_tx, forfeit_cosign_nonces, movement_id
243 } => {
244 finish_offboard(
245 wallet, offboard_vtxo_ids, offboard_tx, forfeit_cosign_nonces, movement_id,
246 ).await?
247 },
248 Progress::ReadyForBroadcast { offboard_vtxo_ids, signed_offboard_tx, movement_id } => {
249 let progress = broadcast_offboard(
254 wallet, offboard_vtxo_ids, signed_offboard_tx, movement_id,
255 ).await?;
256 return Ok(Advance::Park {
257 state: Offboard { progress, ..self },
258 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
259 error: None,
260 });
261 },
262 Progress::AwaitingConfirmations {
263 ref offboard_vtxo_ids, offboard_txid, offboard_tx, movement_id, created_at,
264 } => {
265 return match check_offboard_confirmation(
266 wallet, &offboard_tx, created_at,
267 ).await? {
268 ConfirmationOutcome::Confirmed => {
269 settle_offboard(
270 wallet, offboard_vtxo_ids, movement_id, offboard_txid,
271 ).await?;
272 Ok(Advance::Done)
273 },
274 ConfirmationOutcome::Pending => {
275 Ok(Advance::Park {
276 state: self,
277 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
278 error: None,
279 })
280 },
281 ConfirmationOutcome::Lost => {
282 let error = anyhow!(
287 "offboard tx {} has not been seen on chain since {}; \
288 the server can still commit the signed tx, making the \
289 forfeits valid, so the inputs stay locked; \
290 will keep checking, but manual intervention may be needed",
291 offboard_txid, created_at,
292 );
293 error!("{:#}", error);
294 Ok(Advance::Park {
295 state: self,
296 wake_after: None,
297 error: Some(error.into()),
298 })
299 },
300 }
301 },
302 };
303
304 Ok(Advance::Next(Offboard { progress: new_progress, ..self }))
305 }
306
307 async fn on_retry(
308 self,
309 _wallet: &Wallet,
310 attempts: u32,
311 err: AdvanceError,
312 ) -> anyhow::Result<Advance<Self>> {
313 match self.progress {
314 Progress::Start => {
315 let error = anyhow::Error::from(err).context("Unable to lock VTXOs");
316 return Ok(Advance::Failed(error));
317 },
318 Progress::SplitWithArkoor |
319 Progress::ArkoorRegistrationRequired { .. } |
320 Progress::ReadyForOffboard { .. } |
321 Progress::OffboardTxPrepared { .. } |
322 Progress::ReadyForBroadcast { .. } |
323 Progress::AwaitingConfirmations { .. } => {},
324 }
325 let delay = attempts.pow(2) * BASE_RETRY_BACKOFF;
331 Ok(Advance::Park { state: self, wake_after: Some(delay), error: Some(err) })
332 }
333
334 async fn on_rejection(
335 self,
336 wallet: &Wallet,
337 error: AdvanceError,
338 ) -> anyhow::Result<Advance<Self>> {
339 match &self.progress {
340 Progress::Start | Progress::AwaitingConfirmations { .. } => {
341 debug_assert!(false, "server cannot reject here");
342 error!("Rejection should be impossible here: {:#}", error);
343 Ok(Advance::Park {
344 state: self.clone(),
345 wake_after: None,
346 error: Some(error.into())
347 })
348 },
349 Progress::SplitWithArkoor => {
350 fail_offboard_movement(wallet, &self).await?;
352 Ok(Advance::Failed(error.into()))
353 }
354 Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, .. } |
355 Progress::ReadyForBroadcast { offboard_vtxo_ids, .. } => {
356 error!("Server rejected VTXOs, consider exiting: {:?}", offboard_vtxo_ids);
358 Ok(Advance::Park {
359 state: self.clone(),
360 wake_after: None,
361 error: Some(error.into())
362 })
363 },
364 Progress::ReadyForOffboard { offboard_vtxo_ids, prior_txid: Some(prior_txid) } => {
365 if let Some(progress) = adopt_broadcast_offboard(
369 wallet, &self, offboard_vtxo_ids, *prior_txid,
370 ).await? {
371 return Ok(Advance::Next(Offboard { progress, ..self.clone() }));
372 }
373 if rejection_proves_inputs_spendable(&error) {
374 warn!("Offboard prepare rejected after session loss, cancelling: {:#}", error);
381 fail_offboard_movement(wallet, &self).await?;
382 return Ok(Advance::Failed(error.into()));
383 }
384 error!("Offboard inputs rejected but prior offboard tx {} is not on chain, \
390 will keep looking for it: {:#}", prior_txid, error);
391 Ok(Advance::Park {
392 state: self.clone(),
393 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
394 error: Some(error.into()),
395 })
396 },
397 Progress::ReadyForOffboard { prior_txid: None, .. } => {
398 fail_offboard_movement(wallet, &self).await?;
400 Ok(Advance::Failed(error.into()))
401 },
402 Progress::OffboardTxPrepared { offboard_vtxo_ids, offboard_tx, .. } => {
403 let offboard_txid = offboard_tx.compute_txid();
411 if let Some(progress) = adopt_broadcast_offboard(
412 wallet, &self, offboard_vtxo_ids, offboard_txid,
413 ).await? {
414 return Ok(Advance::Next(Offboard { progress, ..self.clone() }));
415 }
416 warn!("Offboard session for tx {} is gone and the tx is not on chain, \
417 going back to prepare a fresh session: {:#}", offboard_txid, error);
418 let state = Offboard {
419 progress: Progress::ReadyForOffboard {
420 offboard_vtxo_ids: offboard_vtxo_ids.clone(),
421 prior_txid: Some(offboard_txid),
422 },
423 ..self.clone()
424 };
425 Ok(Advance::Park {
429 state,
430 wake_after: Some(CONFIRMATION_POLL_INTERVAL),
431 error: None,
432 })
433 },
434 }
435 }
436}
437
438pub(crate) async fn start_offboard(
446 wallet: &Wallet,
447 destination: bitcoin::Address,
448 spec: StartOffboardSpec,
449) -> anyhow::Result<Offboard> {
450 let (srv, ark) = wallet.require_server().await?;
451 let offboard_feerate = srv.offboard_feerate().await?;
452 let tip = wallet.inner.chain.tip().await?;
453 let destination_spk = destination.script_pubkey();
454 let dust = destination_spk.minimal_non_dust();
455 let id = {
456 let bytes: [u8; 16] = rand::random();
457 bytes.as_hex().to_string()
458 };
459
460 let (net_amount, fee, kind) = match spec {
461 StartOffboardSpec::OffboardWhole { vtxos } => {
462 if vtxos.len() > srv.ark_info().await.max_offboard_inputs {
463 bail!(
464 "max inputs for offboard is {}, {} were provided",
465 srv.ark_info().await.max_offboard_inputs, vtxos.len(),
466 );
467 }
468 let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
469 let fee = ark.fees.offboard.calculate(
470 &destination_spk, vtxos_amount, offboard_feerate,
471 vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
472 ).context("error calculating offboard fee")?;
473 let net_amount = fees::validate_and_subtract_fee_min_dust(vtxos_amount, fee, dust)
474 .context("offboard fee leaves dust")?;
475
476 (net_amount, fee, OffboardKind::OffboardWhole {
477 input_vtxo_ids: vtxos.iter().map(|v| v.id()).collect(),
478 })
479 },
480 StartOffboardSpec::SendOnchain { amount } => {
481 if amount < dust {
482 bail!("the minimum you can send to {} is {}", destination, dust);
483 }
484 let (vtxos, fee) = wallet.spend_input_selection().await?
485 .max_inputs(srv.ark_info().await.max_offboard_inputs)
486 .fee_scheme(wallet.chain().tip().await?, |a, v| {
487 ark.fees.offboard.calculate(&destination_spk, a, offboard_feerate, v)
488 .ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
489 })
490 .select(wallet.spendable_vtxos().await?, amount)?;
491
492 let (_, arkoor_key_index) = wallet.derive_store_next_keypair().await
493 .context("failed to create new keypair")?;
494 let (_, change_key_index) = wallet.derive_store_next_keypair().await
495 .context("failed to create new change keypair")?;
496
497 let input_total = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
498 let change = input_total.checked_sub(amount + fee)
499 .context("selected inputs don't cover amount plus fee")?;
500
501 (amount, fee, OffboardKind::SendOnchain {
502 input_vtxo_ids: vtxos.iter().map(|v| v.id()).collect(),
503 arkoor_key_index,
504 change_key_index,
505 change_pieces: Some(split_change_amount(
506 change, amount + fee, wallet.config().change_vtxo_split_factor,
507 )),
508 })
509 },
510 };
511
512 let input_vtxo_ids_len = kind.vtxo_ids().len();
515 let unique = kind.vtxo_ids().iter().collect::<HashSet<_>>();
516 if input_vtxo_ids_len != unique.len() {
517 bail!("offboard inputs must not contain duplicates");
518 }
519
520 Ok(Offboard {
521 id,
522 kind,
523 destination: destination.into_unchecked(),
524 onchain_output_amount: net_amount,
525 committed_fee: fee,
526 committed_fee_rate: offboard_feerate,
527 progress: Progress::Start,
528 })
529}
530
531async fn lock_vtxos(
533 wallet: &Wallet,
534 action: &Offboard,
535) -> Result<Progress, AdvanceError> {
536 wallet.lock_vtxos(
537 action.kind.vtxo_ids(),
538 Some(VtxoLockHolder::Action { id: action.id.clone() }),
539 ).await?;
540 match &action.kind {
541 OffboardKind::OffboardWhole { input_vtxo_ids } => {
542 Ok(Progress::ReadyForOffboard {
543 offboard_vtxo_ids: input_vtxo_ids.clone(),
544 prior_txid: None,
545 })
546 },
547 OffboardKind::SendOnchain { .. } => {
548 Ok(Progress::SplitWithArkoor)
549 },
550 }
551}
552
553async fn arkoor_split_offboard(
556 wallet: &Wallet,
557 action: &Offboard,
558) -> Result<Progress, AdvanceError> {
559 let OffboardKind::SendOnchain {
560 input_vtxo_ids, arkoor_key_index, change_key_index, change_pieces,
561 } = &action.kind
562 else {
563 return Err(anyhow!("arkoor_split_offboard called for non-SendOnchain kind").into());
564 };
565
566 let mut inputs = Vec::with_capacity(input_vtxo_ids.len());
567 for id in input_vtxo_ids {
568 inputs.push(wallet.get_vtxo_by_id(*id).await
569 .context("failed to load offboard input vtxo")?);
570 }
571
572 let required_amount = action.onchain_output_amount + action.committed_fee;
574 let keypair = wallet.peek_keypair(*arkoor_key_index).await
575 .context("failed to load keypair for offboard action")?;
576 let change_keypair = wallet.peek_keypair(*change_key_index).await
577 .context("failed to load change keypair for offboard action")?;
578 let split_destination = ArkoorDestination {
579 total_amount: required_amount,
580 policy: VtxoPolicy::new_pubkey(keypair.public_key()),
581 };
582 let arkoor = wallet
583 .create_checkpointed_arkoor_with_vtxos(
584 split_destination, inputs.into_iter(), change_keypair, change_pieces.clone(),
585 )
586 .await
587 .context("error preparing offboard vtxos with arkoor")?;
588
589 wallet.store_locked_vtxos(
595 &arkoor.change,
596 Some(VtxoLockHolder::Action { id: action.id.clone() }),
597 ).await.context("error storing change vtxos from preparatory arkoor")?;
598 wallet.store_locked_vtxos(
599 &arkoor.created,
600 Some(VtxoLockHolder::Action { id: action.id.clone() }),
601 ).await.context("error storing offboard vtxos from preparatory arkoor")?;
602 wallet.mark_vtxos_as_spent(&arkoor.inputs).await
603 .context("error marking offboard inputs as spent")?;
604
605 let offboard_vtxo_ids = arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>();
607 let change_vtxo_ids = arkoor.change.iter().map(|v| v.id()).collect::<Vec<_>>();
608 get_or_create_movement(
609 wallet, action, &offboard_vtxo_ids, change_vtxo_ids.iter().copied(),
610 ).await?;
611
612 Ok(Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, change_vtxo_ids })
613}
614
615async fn register_arkoor_split(
619 wallet: &Wallet,
620 action: &Offboard,
621 offboard_vtxo_ids: Vec<VtxoId>,
622 change_vtxo_ids: Vec<VtxoId>,
623) -> Result<Progress, AdvanceError> {
624 let to_register = offboard_vtxo_ids.iter().chain(&change_vtxo_ids).copied().collect::<Vec<_>>();
625 let full_vtxos = wallet.inner.db.get_full_vtxos(&to_register).await
626 .context("failed to hydrate arkoor split vtxos")?;
627
628 wallet.register_vtxo_transactions_with_server(&full_vtxos).await
629 .context("failed to register arkoor split vtxo transactions with server")?;
630
631 wallet.unlock_vtxos(
633 &change_vtxo_ids,
634 Some(VtxoLockHolder::Action { id: action.id.clone() }),
635 ).await.context("failed to unlock change vtxos after registration")?;
636
637 Ok(Progress::ReadyForOffboard { offboard_vtxo_ids, prior_txid: None })
638}
639
640async fn prepare_offboard(
648 wallet: &Wallet,
649 action: &Offboard,
650 mut offboard_vtxo_ids: Vec<VtxoId>,
651) -> Result<Progress, AdvanceError> {
652 let (mut srv, _) = wallet.require_server().await?;
653
654 offboard_vtxo_ids.sort_unstable();
656 debug_assert!(
657 offboard_vtxo_ids.windows(2).all(|w| w[0] != w[1]),
658 "offboard inputs must not contain duplicates",
659 );
660 let vtxos = wallet.inner.db.get_wallet_vtxos(&offboard_vtxo_ids).await
661 .context("failed to load offboard input vtxos")?;
662 debug_assert!(
663 vtxos.iter().map(|v| v.id()).eq(offboard_vtxo_ids.iter().copied()),
664 "get_wallet_vtxos should return inputs in the exact same order",
665 );
666
667 let destination = action.check_destination(wallet.network().await?)?;
672 let destination_spk = destination.script_pubkey();
673 let req = OffboardRequest {
674 script_pubkey: destination_spk,
675 net_amount: action.onchain_output_amount,
676 deduct_fees_from_gross_amount: action.kind.deduct_fees_from_gross_amount(),
677 fee_rate: action.committed_fee_rate,
678 };
679 let attestation = {
680 let mut attestations = Vec::with_capacity(vtxos.len());
681 for v in &vtxos {
682 let key = wallet.get_vtxo_key(v).await?;
683 let att = OffboardRequestAttestation::new(&req, &offboard_vtxo_ids, &key).serialize();
684 attestations.push(att);
685 }
686 attestations
687 };
688
689 let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
692 offboard: Some(req.clone().into()),
693 input_vtxo_ids: offboard_vtxo_ids.iter()
694 .map(|id| id.to_bytes().to_vec())
695 .collect(),
696 attestation,
697 }).await.map_err(AdvanceError::Server)?.into_inner();
698
699 let unsigned_tx = bitcoin::consensus::deserialize::<Transaction>(&prep_resp.offboard_tx)
700 .with_context(|| format!("received invalid unsigned offboard tx from server: {}",
701 prep_resp.offboard_tx.as_hex(),
702 ))?;
703 let offboard_txid = unsigned_tx.compute_txid();
704 let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_tx)
705 .context("no offboard inputs")?;
706 ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
707 info!("Received unsigned offboard tx {} from server", offboard_txid);
708
709 let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
712 musig::PublicNonce::from_bytes(&n)
713 .context("received invalid public cosign nonce from server")
714 }).collect::<anyhow::Result<Vec<_>>>()?;
715
716 OffboardForfeitError::check_count(
722 "forfeit cosign nonces", vtxos.len(), forfeit_cosign_nonces.len(),
723 ).context("invalid prepare_offboard response from server")?;
724
725 let movement_id = get_or_create_movement(
728 wallet, action, &offboard_vtxo_ids, iter::empty::<VtxoId>(),
729 ).await?;
730 Ok(Progress::OffboardTxPrepared {
731 offboard_vtxo_ids,
732 offboard_tx: unsigned_tx,
733 forfeit_cosign_nonces,
734 movement_id,
735 })
736}
737
738async fn finish_offboard(
751 wallet: &Wallet,
752 offboard_vtxo_ids: Vec<VtxoId>,
753 offboard_tx: Transaction,
754 server_forfeit_cosign_nonces: Vec<musig::PublicNonce>,
755 movement_id: MovementId,
756) -> Result<Progress, AdvanceError> {
757 let (mut srv, _) = wallet.require_server().await?;
758
759 let full_inputs = wallet.inner.db.get_full_vtxos(&offboard_vtxo_ids).await
760 .context("failed to hydrate offboard input vtxos")?;
761 debug_assert!(
762 full_inputs.iter().map(|v| v.id()).eq(offboard_vtxo_ids.iter().copied()),
763 "get_full_vtxos should return inputs in the exact same order",
764 );
765 let mut vtxo_keys = Vec::with_capacity(full_inputs.len());
766 for v in &full_inputs {
767 vtxo_keys.push(wallet.get_vtxo_key(v).await?);
768 }
769 let ctx = OffboardForfeitContext::new(&full_inputs, &offboard_tx)
770 .context("no offboard inputs")?;
771 let sigs = ctx.user_sign_forfeits(&vtxo_keys, &server_forfeit_cosign_nonces)
776 .context("stored offboard has an unusable set of forfeit cosign nonces")?;
777
778 let offboard_txid = offboard_tx.compute_txid();
779 let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
780 offboard_txid: offboard_txid.as_byte_array().to_vec(),
781 user_nonces: sigs.public_nonces.iter()
782 .map(|n| n.serialize().to_vec())
783 .collect(),
784 partial_signatures: sigs.partial_signatures.iter()
785 .map(|s| s.serialize().to_vec())
786 .collect(),
787 }).await.map_err(AdvanceError::Server)?.into_inner();
788
789 let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
790 &finish_resp.signed_offboard_tx,
791 ).with_context(|| format!(
792 "received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
793 ))?;
794 if signed_offboard_tx.compute_txid() != offboard_txid {
795 return Err(anyhow!("Signed offboard tx received from server is different from \
796 unsigned tx we forfeited for: unsigned={}, signed={}",
797 serialize_hex(&offboard_tx), finish_resp.signed_offboard_tx.as_hex(),
798 ).into());
799 }
800 if signed_offboard_tx.input.iter().any(|i| i.witness.is_empty() && i.script_sig.is_empty()) {
805 return Err(anyhow!("Signed offboard tx received from server has an unsigned input: {}",
806 finish_resp.signed_offboard_tx.as_hex(),
807 ).into());
808 }
809
810 wallet.inner.movements.update_movement(
811 movement_id,
812 MovementUpdate::new().metadata(OffboardMovement::metadata(&signed_offboard_tx)),
813 ).await.context("failed to update movement with offboard tx")?;
814
815 Ok(Progress::ReadyForBroadcast { offboard_vtxo_ids, signed_offboard_tx, movement_id })
816}
817
818fn rejection_proves_inputs_spendable(error: &AdvanceError) -> bool {
827 let AdvanceError::Server(status) = error else {
828 return false;
829 };
830 let msg = status.message();
832 msg.contains("fee rate is no longer valid")
833 || msg.contains("does not match expected amount")
834 || msg.contains("output address is blocked")
835}
836
837async fn adopt_broadcast_offboard(
845 wallet: &Wallet,
846 action: &Offboard,
847 offboard_vtxo_ids: &Vec<VtxoId>,
848 offboard_txid: Txid,
849) -> anyhow::Result<Option<Progress>> {
850 let tx = wallet.inner.chain.get_tx(&offboard_txid).await
851 .with_context(|| format!("failed to look up offboard tx {} on chain", offboard_txid))?;
852 let Some(offboard_tx) = tx else {
853 return Ok(None);
854 };
855
856 info!("Found offboard tx {} on chain, adopting it", offboard_txid);
857 let movement_id = get_or_create_movement(
858 wallet, action, offboard_vtxo_ids, iter::empty::<VtxoId>(),
859 ).await?;
860 Ok(Some(Progress::AwaitingConfirmations {
861 offboard_vtxo_ids: offboard_vtxo_ids.to_vec(),
862 offboard_txid,
863 offboard_tx,
864 movement_id,
865 created_at: chrono::Utc::now(),
866 }))
867}
868
869async fn broadcast_offboard(
872 wallet: &Wallet,
873 offboard_vtxo_ids: Vec<VtxoId>,
874 offboard_tx: Transaction,
875 movement_id: MovementId,
876) -> Result<Progress, AdvanceError> {
877 let offboard_txid = offboard_tx.compute_txid();
878 wallet.inner.chain.broadcast_tx(&offboard_tx).await.with_context(|| format!(
879 "error broadcasting offboard tx {}", offboard_txid,
880 ))?;
881 Ok(Progress::AwaitingConfirmations {
882 offboard_vtxo_ids,
883 offboard_txid,
884 offboard_tx,
885 movement_id,
886 created_at: chrono::Utc::now(),
887 })
888}
889
890async fn settle_offboard(
895 wallet: &Wallet,
896 offboard_vtxo_ids: &[VtxoId],
897 movement_id: MovementId,
898 offboard_txid: Txid,
899) -> anyhow::Result<()> {
900 info!("Offboard tx {} confirmed, finalizing movement {}",
901 offboard_txid, movement_id);
902
903 wallet.inner.db.update_vtxo_states_checked(
909 offboard_vtxo_ids,
910 VtxoState::Spent,
911 &[VtxoStateKind::Locked, VtxoStateKind::Spent],
912 ).await.context("failed to mark offboard vtxos as spent")?;
913
914 wallet.inner.movements.finish_movement(movement_id, MovementStatus::Successful).await
915 .context("failed to finish offboard movement")?;
916 Ok(())
917}
918
919async fn check_offboard_confirmation(
922 wallet: &Wallet,
923 offboard_tx: &Transaction,
924 created_at: chrono::DateTime<chrono::Utc>,
925) -> anyhow::Result<ConfirmationOutcome> {
926 let offboard_txid = offboard_tx.compute_txid();
927 let required_confs = wallet.inner.config.offboard_required_confirmations;
928 let current_height = wallet.inner.chain.tip().await
929 .context("error fetching chain tip")?;
930 let status = wallet.inner.chain.tx_status(offboard_txid).await;
931
932 match status {
933 Ok(TxStatus::Confirmed(block_ref)) => {
934 let confs = current_height - (block_ref.height - 1);
935 if confs >= required_confs as BlockHeight {
936 Ok(ConfirmationOutcome::Confirmed)
937 } else {
938 trace!(
939 "Offboard tx {} has {}/{} confirmations, waiting...",
940 offboard_txid, confs, required_confs,
941 );
942 Ok(ConfirmationOutcome::Pending)
943 }
944 },
945 Ok(TxStatus::Mempool) => {
946 if required_confs == 0 {
947 Ok(ConfirmationOutcome::Confirmed)
948 } else {
949 trace!("Offboard tx {} still in mempool, waiting...", offboard_txid);
950 Ok(ConfirmationOutcome::Pending)
951 }
952 },
953 Ok(TxStatus::NotFound) => {
954 let age = chrono::Utc::now() - created_at;
955 let grace_period = chrono::Duration::seconds(
956 wallet.inner.config.offboard_lost_tx_grace_period_secs as i64,
957 );
958 if age > grace_period {
959 return Ok(ConfirmationOutcome::Lost);
960 }
961 trace!("Offboard tx {} not found — re-broadcasting...", offboard_txid);
962 wallet.inner.chain.broadcast_tx(&offboard_tx).await.with_context(|| format!(
963 "error broadcasting offboard tx {}", offboard_txid,
964 ))?;
965 Ok(ConfirmationOutcome::Pending)
966 },
967 Err(e) => {
968 warn!("Failed to check status of offboard tx {}: {:#}", offboard_txid, e);
969 Ok(ConfirmationOutcome::Pending)
970 },
971 }
972}
973
974async fn get_or_create_movement(
976 wallet: &Wallet,
977 action: &Offboard,
978 offboard_vtxo_ids: &Vec<VtxoId>,
979 change: impl IntoIterator<Item = impl VtxoRef>,
980) -> anyhow::Result<MovementId> {
981 let destination = action.check_destination(wallet.network().await?)?;
982 let net = action.onchain_output_amount;
983 let required = net.checked_add(action.committed_fee).context("overflow")?;
984 match &action.kind {
985 OffboardKind::OffboardWhole { .. } => {
986 let effective_amt = -SignedAmount::try_from(required)
987 .context("can't have this many vtxo sats")?;
988 wallet.inner.movements.get_or_create_movement_with_action(
989 Subsystem::OFFBOARD,
990 OffboardMovement::Offboard.to_string(),
991 &action.id,
992 MovementUpdate::new()
993 .intended_balance(effective_amt)
994 .effective_balance(effective_amt)
995 .fee(action.committed_fee)
996 .consumed_vtxos(offboard_vtxo_ids)
997 .sent_to([MovementDestination::bitcoin(destination, net)]),
998 ).await.context("failed to create offboard movement")
999 },
1000 OffboardKind::SendOnchain { input_vtxo_ids, .. } => {
1001 wallet.inner.movements.get_or_create_movement_with_action(
1002 Subsystem::OFFBOARD,
1003 OffboardMovement::SendOnchain.to_string(),
1004 &action.id,
1005 MovementUpdate::new()
1006 .intended_balance(-net.to_signed().context("amount out of range")?)
1007 .effective_balance(-required.to_signed().context("required amount out of range")?)
1008 .fee(action.committed_fee)
1009 .consumed_vtxos(input_vtxo_ids)
1010 .produced_vtxos(change)
1011 .metadata([(
1012 "offboard_vtxos".into(),
1013 serde_json::to_value(offboard_vtxo_ids).expect("offboard_vtxos can serde"),
1014 )])
1015 .sent_to([MovementDestination::bitcoin(destination, net)]),
1016 ).await.context("failed to create send-onchain movement")
1017 }
1018 }
1019}
1020
1021async fn fail_offboard_movement(
1030 wallet: &Wallet,
1031 action: &Offboard,
1032) -> anyhow::Result<()> {
1033 let offboard_vtxo_ids = action.kind.vtxo_ids();
1034 let movement_id = get_or_create_movement(
1035 wallet, action, offboard_vtxo_ids, iter::empty::<VtxoId>(),
1036 ).await?;
1037 wallet.inner.movements.finish_movement_with_update(
1041 movement_id,
1042 MovementStatus::Failed,
1043 MovementUpdate::new().effective_balance(SignedAmount::ZERO),
1044 ).await.context("failed to mark offboard movement as failed")
1045}