use std::collections::HashSet;
use std::iter;
use std::time::Duration;
use anyhow::Context;
use bitcoin::consensus::encode::serialize_hex;
use bitcoin::hex::DisplayHex;
use bitcoin::{Amount, FeeRate, SignedAmount, Transaction, Txid};
use bitcoin::hashes::Hash;
use log::{error, info, trace, warn};
use ark::{musig, ProtocolEncoding, VtxoPolicy, VtxoId, fees};
use ark::arkoor::ArkoorDestination;
use ark::attestations::OffboardRequestAttestation;
use ark::fees::VtxoFeeInfo;
use ark::offboard::{OffboardForfeitContext, OffboardRequest};
use ark::vtxo::VtxoRef;
use bitcoin_ext::{BlockHeight, TxStatus};
use server_rpc::{protos, TryFromBytes};
use crate::{Wallet, WalletVtxo};
use crate::actions::{Advance, AdvanceError, WalletAction, WalletActionId, BASE_RETRY_BACKOFF};
use crate::movement::update::MovementUpdate;
use crate::movement::{MovementDestination, MovementId, MovementStatus};
use crate::subsystem::{OffboardMovement, Subsystem};
use crate::vtxo::{VtxoLockHolder, VtxoState, VtxoStateKind};
use crate::vtxo::selection::InputSelection;
pub(crate) const CONFIRMATION_POLL_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Offboard {
pub id: WalletActionId,
pub destination: bitcoin::Address<bitcoin::address::NetworkUnchecked>,
#[serde(with = "bitcoin::amount::serde::as_sat")]
pub onchain_output_amount: Amount,
#[serde(with = "bitcoin::amount::serde::as_sat")]
pub committed_fee: Amount,
pub committed_fee_rate: FeeRate,
pub kind: OffboardKind,
pub progress: Progress,
}
impl Offboard {
pub fn id(&self) -> WalletActionId {
self.id.clone()
}
pub fn check_destination(&self, network: bitcoin::Network) -> anyhow::Result<bitcoin::Address> {
Ok(self.destination.clone().require_network(network)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum OffboardKind {
OffboardWhole {
input_vtxo_ids: Vec<VtxoId>,
},
SendOnchain {
input_vtxo_ids: Vec<VtxoId>,
arkoor_key_index: u32,
change_key_index: u32,
},
}
impl OffboardKind {
fn deduct_fees_from_gross_amount(&self) -> bool {
match self {
OffboardKind::OffboardWhole { .. } => true,
OffboardKind::SendOnchain { .. } => false,
}
}
fn vtxo_ids(&self) -> &Vec<VtxoId> {
match self {
OffboardKind::OffboardWhole { input_vtxo_ids } => input_vtxo_ids,
OffboardKind::SendOnchain { input_vtxo_ids, .. } => input_vtxo_ids,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Progress {
Start,
SplitWithArkoor,
ArkoorRegistrationRequired {
offboard_vtxo_ids: Vec<VtxoId>,
change_vtxo_ids: Vec<VtxoId>,
},
ReadyForOffboard {
offboard_vtxo_ids: Vec<VtxoId>,
#[serde(default)]
prior_txid: Option<Txid>,
},
OffboardTxPrepared {
offboard_vtxo_ids: Vec<VtxoId>,
#[serde(with = "bitcoin_ext::serde::encodable")]
offboard_tx: Transaction,
forfeit_cosign_nonces: Vec<musig::PublicNonce>,
movement_id: MovementId,
},
ReadyForBroadcast {
offboard_vtxo_ids: Vec<VtxoId>,
#[serde(with = "bitcoin_ext::serde::encodable")]
signed_offboard_tx: Transaction,
movement_id: MovementId,
},
AwaitingConfirmations {
offboard_vtxo_ids: Vec<VtxoId>,
offboard_txid: Txid,
#[serde(with = "bitcoin_ext::serde::encodable")]
offboard_tx: Transaction,
movement_id: MovementId,
created_at: chrono::DateTime<chrono::Utc>,
},
}
pub(crate) enum ConfirmationOutcome {
Confirmed,
Pending,
Lost,
}
pub enum StartOffboardSpec {
OffboardWhole { vtxos: Vec<WalletVtxo> },
SendOnchain { amount: Amount },
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
impl WalletAction for Offboard {
fn id(&self) -> WalletActionId { Offboard::id(self) }
async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError> {
let new_progress = match self.progress.clone() {
Progress::Start => {
lock_vtxos(wallet, &self).await?
},
Progress::SplitWithArkoor => {
arkoor_split_offboard(wallet, &self).await?
}
Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, change_vtxo_ids } => {
register_arkoor_split(wallet, offboard_vtxo_ids, change_vtxo_ids).await?
},
Progress::ReadyForOffboard { offboard_vtxo_ids, .. } => {
prepare_offboard(wallet, &self, offboard_vtxo_ids).await?
},
Progress::OffboardTxPrepared {
offboard_vtxo_ids, offboard_tx, forfeit_cosign_nonces, movement_id
} => {
finish_offboard(
wallet, offboard_vtxo_ids, offboard_tx, forfeit_cosign_nonces, movement_id,
).await?
},
Progress::ReadyForBroadcast { offboard_vtxo_ids, signed_offboard_tx, movement_id } => {
let progress = broadcast_offboard(
wallet, offboard_vtxo_ids, signed_offboard_tx, movement_id,
).await?;
return Ok(Advance::Park {
state: Offboard { progress, ..self },
wake_after: Some(CONFIRMATION_POLL_INTERVAL),
error: None,
});
},
Progress::AwaitingConfirmations {
ref offboard_vtxo_ids, offboard_txid, offboard_tx, movement_id, created_at,
} => {
return match check_offboard_confirmation(
wallet, &offboard_tx, created_at,
).await? {
ConfirmationOutcome::Confirmed => {
settle_offboard(
wallet, offboard_vtxo_ids, movement_id, offboard_txid,
).await?;
Ok(Advance::Done)
},
ConfirmationOutcome::Pending => {
Ok(Advance::Park {
state: self,
wake_after: Some(CONFIRMATION_POLL_INTERVAL),
error: None,
})
},
ConfirmationOutcome::Lost => {
let error = anyhow!(
"offboard tx {} has not been seen on chain since {}; \
the server can still commit the signed tx, making the \
forfeits valid, so the inputs stay locked; \
will keep checking, but manual intervention may be needed",
offboard_txid, created_at,
);
error!("{:#}", error);
Ok(Advance::Park {
state: self,
wake_after: None,
error: Some(error.into()),
})
},
}
},
};
Ok(Advance::Next(Offboard { progress: new_progress, ..self }))
}
async fn on_retry(
self,
_wallet: &Wallet,
attempts: u32,
err: AdvanceError,
) -> anyhow::Result<Advance<Self>> {
match self.progress {
Progress::Start => {
let error = anyhow::Error::from(err).context("Unable to lock VTXOs");
return Ok(Advance::Failed(error));
},
Progress::SplitWithArkoor |
Progress::ArkoorRegistrationRequired { .. } |
Progress::ReadyForOffboard { .. } |
Progress::OffboardTxPrepared { .. } |
Progress::ReadyForBroadcast { .. } |
Progress::AwaitingConfirmations { .. } => {},
}
let delay = attempts.pow(2) * BASE_RETRY_BACKOFF;
Ok(Advance::Park { state: self, wake_after: Some(delay), error: Some(err) })
}
async fn on_rejection(
self,
wallet: &Wallet,
error: AdvanceError,
) -> anyhow::Result<Advance<Self>> {
match &self.progress {
Progress::Start | Progress::AwaitingConfirmations { .. } => {
debug_assert!(false, "server cannot reject here");
error!("Rejection should be impossible here: {:#}", error);
Ok(Advance::Park {
state: self.clone(),
wake_after: None,
error: Some(error.into())
})
},
Progress::SplitWithArkoor => {
fail_offboard_movement(wallet, &self).await?;
Ok(Advance::Failed(error.into()))
}
Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, .. } |
Progress::ReadyForBroadcast { offboard_vtxo_ids, .. } => {
error!("Server rejected VTXOs, consider exiting: {:?}", offboard_vtxo_ids);
Ok(Advance::Park {
state: self.clone(),
wake_after: None,
error: Some(error.into())
})
},
Progress::ReadyForOffboard { offboard_vtxo_ids, prior_txid: Some(prior_txid) } => {
if let Some(progress) = adopt_broadcast_offboard(
wallet, &self, offboard_vtxo_ids, *prior_txid,
).await? {
return Ok(Advance::Next(Offboard { progress, ..self.clone() }));
}
if rejection_proves_inputs_spendable(&error) {
warn!("Offboard prepare rejected after session loss, cancelling: {:#}", error);
fail_offboard_movement(wallet, &self).await?;
return Ok(Advance::Failed(error.into()));
}
error!("Offboard inputs rejected but prior offboard tx {} is not on chain, \
will keep looking for it: {:#}", prior_txid, error);
Ok(Advance::Park {
state: self.clone(),
wake_after: Some(CONFIRMATION_POLL_INTERVAL),
error: Some(error.into()),
})
},
Progress::ReadyForOffboard { prior_txid: None, .. } => {
fail_offboard_movement(wallet, &self).await?;
Ok(Advance::Failed(error.into()))
},
Progress::OffboardTxPrepared { offboard_vtxo_ids, offboard_tx, .. } => {
let offboard_txid = offboard_tx.compute_txid();
if let Some(progress) = adopt_broadcast_offboard(
wallet, &self, offboard_vtxo_ids, offboard_txid,
).await? {
return Ok(Advance::Next(Offboard { progress, ..self.clone() }));
}
warn!("Offboard session for tx {} is gone and the tx is not on chain, \
going back to prepare a fresh session: {:#}", offboard_txid, error);
let state = Offboard {
progress: Progress::ReadyForOffboard {
offboard_vtxo_ids: offboard_vtxo_ids.clone(),
prior_txid: Some(offboard_txid),
},
..self.clone()
};
Ok(Advance::Park {
state,
wake_after: Some(CONFIRMATION_POLL_INTERVAL),
error: None,
})
},
}
}
}
pub(crate) async fn start_offboard(
wallet: &Wallet,
destination: bitcoin::Address,
spec: StartOffboardSpec,
) -> anyhow::Result<Offboard> {
let (srv, ark) = wallet.require_server().await?;
let offboard_feerate = srv.offboard_feerate().await?;
let tip = wallet.inner.chain.tip().await?;
let destination_spk = destination.script_pubkey();
let dust = destination_spk.minimal_non_dust();
let id = {
let bytes: [u8; 16] = rand::random();
bytes.as_hex().to_string()
};
let (net_amount, fee, kind) = match spec {
StartOffboardSpec::OffboardWhole { vtxos } => {
if vtxos.len() > srv.ark_info().await.max_offboard_inputs {
bail!(
"max inputs for offboard is {}, {} were provided",
srv.ark_info().await.max_offboard_inputs, vtxos.len(),
);
}
let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
let fee = ark.fees.offboard.calculate(
&destination_spk, vtxos_amount, offboard_feerate,
vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
).context("error calculating offboard fee")?;
let net_amount = fees::validate_and_subtract_fee_min_dust(vtxos_amount, fee, dust)
.context("offboard fee leaves dust")?;
(net_amount, fee, OffboardKind::OffboardWhole {
input_vtxo_ids: vtxos.iter().map(|v| v.id()).collect(),
})
},
StartOffboardSpec::SendOnchain { amount } => {
if amount < dust {
bail!("the minimum you can send to {} is {}", destination, dust);
}
let (vtxos, fee) = InputSelection::new()
.max_inputs(srv.ark_info().await.max_offboard_inputs)
.fee_scheme(wallet.chain().tip().await?, |a, v| {
ark.fees.offboard.calculate(&destination_spk, a, offboard_feerate, v)
.ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
})
.select(wallet.spendable_vtxos().await?, amount)?;
let (_, arkoor_key_index) = wallet.derive_store_next_keypair().await
.context("failed to create new keypair")?;
let (_, change_key_index) = wallet.derive_store_next_keypair().await
.context("failed to create new change keypair")?;
(amount, fee, OffboardKind::SendOnchain {
input_vtxo_ids: vtxos.iter().map(|v| v.id()).collect(),
arkoor_key_index,
change_key_index,
})
},
};
let input_vtxo_ids_len = kind.vtxo_ids().len();
let unique = kind.vtxo_ids().iter().collect::<HashSet<_>>();
if input_vtxo_ids_len != unique.len() {
bail!("offboard inputs must not contain duplicates");
}
Ok(Offboard {
id,
kind,
destination: destination.into_unchecked(),
onchain_output_amount: net_amount,
committed_fee: fee,
committed_fee_rate: offboard_feerate,
progress: Progress::Start,
})
}
async fn lock_vtxos(
wallet: &Wallet,
action: &Offboard,
) -> Result<Progress, AdvanceError> {
wallet.lock_vtxos(
action.kind.vtxo_ids(),
Some(VtxoLockHolder::Action { id: action.id.clone() }),
).await?;
match &action.kind {
OffboardKind::OffboardWhole { input_vtxo_ids } => {
Ok(Progress::ReadyForOffboard {
offboard_vtxo_ids: input_vtxo_ids.clone(),
prior_txid: None,
})
},
OffboardKind::SendOnchain { .. } => {
Ok(Progress::SplitWithArkoor)
},
}
}
async fn arkoor_split_offboard(
wallet: &Wallet,
action: &Offboard,
) -> Result<Progress, AdvanceError> {
let OffboardKind::SendOnchain {
input_vtxo_ids, arkoor_key_index, change_key_index,
} = &action.kind
else {
return Err(anyhow!("arkoor_split_offboard called for non-SendOnchain kind").into());
};
let mut inputs = Vec::with_capacity(input_vtxo_ids.len());
for id in input_vtxo_ids {
inputs.push(wallet.get_vtxo_by_id(*id).await
.context("failed to load offboard input vtxo")?);
}
let required_amount = action.onchain_output_amount + action.committed_fee;
let keypair = wallet.peek_keypair(*arkoor_key_index).await
.context("failed to load keypair for offboard action")?;
let change_keypair = wallet.peek_keypair(*change_key_index).await
.context("failed to load change keypair for offboard action")?;
let split_destination = ArkoorDestination {
total_amount: required_amount,
policy: VtxoPolicy::new_pubkey(keypair.public_key()),
};
let arkoor = wallet
.create_checkpointed_arkoor_with_vtxos(split_destination, inputs.into_iter(), change_keypair)
.await
.context("error preparing offboard vtxos with arkoor")?;
wallet.store_locked_vtxos(
&arkoor.change,
Some(VtxoLockHolder::Action { id: action.id.clone() }),
).await.context("error storing change vtxos from preparatory arkoor")?;
wallet.store_locked_vtxos(
&arkoor.created,
Some(VtxoLockHolder::Action { id: action.id.clone() }),
).await.context("error storing offboard vtxos from preparatory arkoor")?;
wallet.mark_vtxos_as_spent(&arkoor.inputs).await
.context("error marking offboard inputs as spent")?;
let offboard_vtxo_ids = arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>();
let change_vtxo_ids = arkoor.change.iter().map(|v| v.id()).collect::<Vec<_>>();
get_or_create_movement(
wallet, action, &offboard_vtxo_ids, change_vtxo_ids.iter().copied(),
).await?;
Ok(Progress::ArkoorRegistrationRequired { offboard_vtxo_ids, change_vtxo_ids })
}
async fn register_arkoor_split(
wallet: &Wallet,
offboard_vtxo_ids: Vec<VtxoId>,
change_vtxo_ids: Vec<VtxoId>,
) -> Result<Progress, AdvanceError> {
let to_register = offboard_vtxo_ids.iter().chain(&change_vtxo_ids).copied().collect::<Vec<_>>();
let full_vtxos = wallet.inner.db.get_full_vtxos(&to_register).await
.context("failed to hydrate arkoor split vtxos")?;
wallet.register_vtxo_transactions_with_server(&full_vtxos).await
.context("failed to register arkoor split vtxo transactions with server")?;
wallet.unlock_vtxos(&change_vtxo_ids).await
.context("failed to unlock change vtxos after registration")?;
Ok(Progress::ReadyForOffboard { offboard_vtxo_ids, prior_txid: None })
}
async fn prepare_offboard(
wallet: &Wallet,
action: &Offboard,
mut offboard_vtxo_ids: Vec<VtxoId>,
) -> Result<Progress, AdvanceError> {
let (mut srv, _) = wallet.require_server().await?;
offboard_vtxo_ids.sort_unstable();
debug_assert!(
offboard_vtxo_ids.windows(2).all(|w| w[0] != w[1]),
"offboard inputs must not contain duplicates",
);
let vtxos = wallet.inner.db.get_wallet_vtxos(&offboard_vtxo_ids).await
.context("failed to load offboard input vtxos")?;
debug_assert!(
vtxos.iter().map(|v| v.id()).eq(offboard_vtxo_ids.iter().copied()),
"get_wallet_vtxos should return inputs in the exact same order",
);
let destination = action.check_destination(wallet.network().await?)?;
let destination_spk = destination.script_pubkey();
let req = OffboardRequest {
script_pubkey: destination_spk,
net_amount: action.onchain_output_amount,
deduct_fees_from_gross_amount: action.kind.deduct_fees_from_gross_amount(),
fee_rate: action.committed_fee_rate,
};
let attestation = {
let mut attestations = Vec::with_capacity(vtxos.len());
for v in &vtxos {
let key = wallet.get_vtxo_key(v).await?;
let att = OffboardRequestAttestation::new(&req, &offboard_vtxo_ids, &key).serialize();
attestations.push(att);
}
attestations
};
let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
offboard: Some(req.clone().into()),
input_vtxo_ids: offboard_vtxo_ids.iter()
.map(|id| id.to_bytes().to_vec())
.collect(),
attestation,
}).await.map_err(AdvanceError::Server)?.into_inner();
let unsigned_tx = bitcoin::consensus::deserialize::<Transaction>(&prep_resp.offboard_tx)
.with_context(|| format!("received invalid unsigned offboard tx from server: {}",
prep_resp.offboard_tx.as_hex(),
))?;
let offboard_txid = unsigned_tx.compute_txid();
let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_tx);
ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
info!("Received unsigned offboard tx {} from server", offboard_txid);
let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
musig::PublicNonce::from_bytes(&n)
.context("received invalid public cosign nonce from server")
}).collect::<anyhow::Result<Vec<_>>>()?;
let movement_id = get_or_create_movement(
wallet, action, &offboard_vtxo_ids, iter::empty::<VtxoId>(),
).await?;
Ok(Progress::OffboardTxPrepared {
offboard_vtxo_ids,
offboard_tx: unsigned_tx,
forfeit_cosign_nonces,
movement_id,
})
}
async fn finish_offboard(
wallet: &Wallet,
offboard_vtxo_ids: Vec<VtxoId>,
offboard_tx: Transaction,
server_forfeit_cosign_nonces: Vec<musig::PublicNonce>,
movement_id: MovementId,
) -> Result<Progress, AdvanceError> {
let (mut srv, _) = wallet.require_server().await?;
let full_inputs = wallet.inner.db.get_full_vtxos(&offboard_vtxo_ids).await
.context("failed to hydrate offboard input vtxos")?;
debug_assert!(
full_inputs.iter().map(|v| v.id()).eq(offboard_vtxo_ids.iter().copied()),
"get_full_vtxos should return inputs in the exact same order",
);
let mut vtxo_keys = Vec::with_capacity(full_inputs.len());
for v in &full_inputs {
vtxo_keys.push(wallet.get_vtxo_key(v).await?);
}
let ctx = OffboardForfeitContext::new(&full_inputs, &offboard_tx);
let sigs = ctx.user_sign_forfeits(&vtxo_keys, &server_forfeit_cosign_nonces);
let offboard_txid = offboard_tx.compute_txid();
let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
offboard_txid: offboard_txid.as_byte_array().to_vec(),
user_nonces: sigs.public_nonces.iter()
.map(|n| n.serialize().to_vec())
.collect(),
partial_signatures: sigs.partial_signatures.iter()
.map(|s| s.serialize().to_vec())
.collect(),
}).await.map_err(AdvanceError::Server)?.into_inner();
let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
&finish_resp.signed_offboard_tx,
).with_context(|| format!(
"received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
))?;
if signed_offboard_tx.compute_txid() != offboard_txid {
return Err(anyhow!("Signed offboard tx received from server is different from \
unsigned tx we forfeited for: unsigned={}, signed={}",
serialize_hex(&offboard_tx), finish_resp.signed_offboard_tx.as_hex(),
).into());
}
if signed_offboard_tx.input.iter().any(|i| i.witness.is_empty() && i.script_sig.is_empty()) {
return Err(anyhow!("Signed offboard tx received from server has an unsigned input: {}",
finish_resp.signed_offboard_tx.as_hex(),
).into());
}
wallet.inner.movements.update_movement(
movement_id,
MovementUpdate::new().metadata(OffboardMovement::metadata(&signed_offboard_tx)),
).await.context("failed to update movement with offboard tx")?;
Ok(Progress::ReadyForBroadcast { offboard_vtxo_ids, signed_offboard_tx, movement_id })
}
fn rejection_proves_inputs_spendable(error: &AdvanceError) -> bool {
let AdvanceError::Server(status) = error else {
return false;
};
let msg = status.message();
msg.contains("fee rate is no longer valid")
|| msg.contains("does not match expected amount")
|| msg.contains("output address is blocked")
}
async fn adopt_broadcast_offboard(
wallet: &Wallet,
action: &Offboard,
offboard_vtxo_ids: &Vec<VtxoId>,
offboard_txid: Txid,
) -> anyhow::Result<Option<Progress>> {
let tx = wallet.inner.chain.get_tx(&offboard_txid).await
.with_context(|| format!("failed to look up offboard tx {} on chain", offboard_txid))?;
let Some(offboard_tx) = tx else {
return Ok(None);
};
info!("Found offboard tx {} on chain, adopting it", offboard_txid);
let movement_id = get_or_create_movement(
wallet, action, offboard_vtxo_ids, iter::empty::<VtxoId>(),
).await?;
Ok(Some(Progress::AwaitingConfirmations {
offboard_vtxo_ids: offboard_vtxo_ids.to_vec(),
offboard_txid,
offboard_tx,
movement_id,
created_at: chrono::Utc::now(),
}))
}
async fn broadcast_offboard(
wallet: &Wallet,
offboard_vtxo_ids: Vec<VtxoId>,
offboard_tx: Transaction,
movement_id: MovementId,
) -> Result<Progress, AdvanceError> {
let offboard_txid = offboard_tx.compute_txid();
wallet.inner.chain.broadcast_tx(&offboard_tx).await.with_context(|| format!(
"error broadcasting offboard tx {}", offboard_txid,
))?;
Ok(Progress::AwaitingConfirmations {
offboard_vtxo_ids,
offboard_txid,
offboard_tx,
movement_id,
created_at: chrono::Utc::now(),
})
}
async fn settle_offboard(
wallet: &Wallet,
offboard_vtxo_ids: &[VtxoId],
movement_id: MovementId,
offboard_txid: Txid,
) -> anyhow::Result<()> {
info!("Offboard tx {} confirmed, finalizing movement {}",
offboard_txid, movement_id);
wallet.inner.db.update_vtxo_states_checked(
offboard_vtxo_ids,
VtxoState::Spent,
&[VtxoStateKind::Locked, VtxoStateKind::Spent],
).await.context("failed to mark offboard vtxos as spent")?;
wallet.inner.movements.finish_movement(movement_id, MovementStatus::Successful).await
.context("failed to finish offboard movement")?;
Ok(())
}
async fn check_offboard_confirmation(
wallet: &Wallet,
offboard_tx: &Transaction,
created_at: chrono::DateTime<chrono::Utc>,
) -> anyhow::Result<ConfirmationOutcome> {
let offboard_txid = offboard_tx.compute_txid();
let required_confs = wallet.inner.config.offboard_required_confirmations;
let current_height = wallet.inner.chain.tip().await
.context("error fetching chain tip")?;
let status = wallet.inner.chain.tx_status(offboard_txid).await;
match status {
Ok(TxStatus::Confirmed(block_ref)) => {
let confs = current_height - (block_ref.height - 1);
if confs >= required_confs as BlockHeight {
Ok(ConfirmationOutcome::Confirmed)
} else {
trace!(
"Offboard tx {} has {}/{} confirmations, waiting...",
offboard_txid, confs, required_confs,
);
Ok(ConfirmationOutcome::Pending)
}
},
Ok(TxStatus::Mempool) => {
if required_confs == 0 {
Ok(ConfirmationOutcome::Confirmed)
} else {
trace!("Offboard tx {} still in mempool, waiting...", offboard_txid);
Ok(ConfirmationOutcome::Pending)
}
},
Ok(TxStatus::NotFound) => {
let age = chrono::Utc::now() - created_at;
let grace_period = chrono::Duration::seconds(
wallet.inner.config.offboard_lost_tx_grace_period_secs as i64,
);
if age > grace_period {
return Ok(ConfirmationOutcome::Lost);
}
trace!("Offboard tx {} not found — re-broadcasting...", offboard_txid);
wallet.inner.chain.broadcast_tx(&offboard_tx).await.with_context(|| format!(
"error broadcasting offboard tx {}", offboard_txid,
))?;
Ok(ConfirmationOutcome::Pending)
},
Err(e) => {
warn!("Failed to check status of offboard tx {}: {:#}", offboard_txid, e);
Ok(ConfirmationOutcome::Pending)
},
}
}
async fn get_or_create_movement(
wallet: &Wallet,
action: &Offboard,
offboard_vtxo_ids: &Vec<VtxoId>,
change: impl IntoIterator<Item = impl VtxoRef>,
) -> anyhow::Result<MovementId> {
let destination = action.check_destination(wallet.network().await?)?;
let net = action.onchain_output_amount;
let required = net.checked_add(action.committed_fee).context("overflow")?;
match &action.kind {
OffboardKind::OffboardWhole { .. } => {
let effective_amt = -SignedAmount::try_from(required)
.context("can't have this many vtxo sats")?;
wallet.inner.movements.get_or_create_movement_with_action(
Subsystem::OFFBOARD,
OffboardMovement::Offboard.to_string(),
&action.id,
MovementUpdate::new()
.intended_balance(effective_amt)
.effective_balance(effective_amt)
.fee(action.committed_fee)
.consumed_vtxos(offboard_vtxo_ids)
.sent_to([MovementDestination::bitcoin(destination, net)]),
).await.context("failed to create offboard movement")
},
OffboardKind::SendOnchain { input_vtxo_ids, .. } => {
wallet.inner.movements.get_or_create_movement_with_action(
Subsystem::OFFBOARD,
OffboardMovement::SendOnchain.to_string(),
&action.id,
MovementUpdate::new()
.intended_balance(-net.to_signed().context("amount out of range")?)
.effective_balance(-required.to_signed().context("required amount out of range")?)
.fee(action.committed_fee)
.consumed_vtxos(input_vtxo_ids)
.produced_vtxos(change)
.metadata([(
"offboard_vtxos".into(),
serde_json::to_value(offboard_vtxo_ids).expect("offboard_vtxos can serde"),
)])
.sent_to([MovementDestination::bitcoin(destination, net)]),
).await.context("failed to create send-onchain movement")
}
}
}
async fn fail_offboard_movement(
wallet: &Wallet,
action: &Offboard,
) -> anyhow::Result<()> {
let offboard_vtxo_ids = action.kind.vtxo_ids();
let movement_id = get_or_create_movement(
wallet, action, offboard_vtxo_ids, iter::empty::<VtxoId>(),
).await?;
wallet.inner.movements.finish_movement_with_update(
movement_id,
MovementStatus::Failed,
MovementUpdate::new().effective_balance(SignedAmount::ZERO),
).await.context("failed to mark offboard movement as failed")
}