Skip to main content

bark/actions/
offboard.rs

1//! State machine for outgoing offboards (offboard whole vtxos *and*
2//! arkoor-prep-then-offboard for [`Wallet::send_onchain`]).
3//!
4//! Identity (`id`, `destination`, `fee_rate`, `kind`) and the parameters
5//! fixed at the start (inputs, amounts) live on the action as top-level
6//! fields; the mutable bit is [`Progress`], a small enum that names the
7//! phases of the state machine and only carries the fields the phase
8//! actually has.
9//!
10//! Both entry points share the same skeleton:
11//! - [`start_offboard`] selects inputs and derives the offboard key (both
12//!   for `SendOnchain` only), validates fee and dust constraints, and
13//!   returns the action in [`Progress::Start`]; [`lock_vtxos`] then locks
14//!   the inputs under the action id.
15//! - For `SendOnchain` only, [`arkoor_split_offboard`] runs an arkoor to
16//!   produce an exact-sized offboard vtxo plus change, which
17//!   [`register_arkoor_split`] registers with the server.
18//! - Both kinds converge in [`prepare_offboard`], which has the server
19//!   build the offboard tx and validates it
20//!   ([`Progress::OffboardTxPrepared`]), and [`finish_offboard`], which
21//!   signs our forfeits and trades them for the signed offboard tx
22//!   ([`Progress::ReadyForBroadcast`]).
23//! - [`broadcast_offboard`] publishes the signed tx
24//!   ([`Progress::AwaitingConfirmations`]).
25//! - [`settle_offboard`] marks the vtxos spent and finalises the movement
26//!   once the tx has enough confirmations.
27
28use 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
56/// How long to sleep between confirmation polls while a tx is in
57/// mempool or has too few confirmations.
58pub(crate) const CONFIRMATION_POLL_INTERVAL: Duration = Duration::from_secs(30);
59
60/// An outgoing offboard, persisted as a single checkpoint row and
61/// driven across crashes by the executor.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Offboard {
64	// Set at start, immutable thereafter:
65	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	// Mutable state:
75	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/// Which flavour of offboard this action drives.
89///
90/// `OffboardWhole` is reached from [`Wallet::offboard`] / [`Wallet::offboard_all`]
91/// / [`Wallet::offboard_vtxos`]: the inputs are forfeited directly to
92/// the offboard tx, fees come out of the gross amount.
93///
94/// `SendOnchain` is reached from [`Wallet::send_onchain`]: the user gives an
95/// amount and the wallet must first arkoor-split its vtxos into an
96/// exact-sized output (held by `offboard_pubkey`) plus change.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub enum OffboardKind {
99	/// Forfeit the listed vtxos as-is to the offboard tx.
100	OffboardWhole {
101		input_vtxo_ids: Vec<VtxoId>,
102	},
103	/// Run an arkoor first; offboard the resulting exact-sized vtxo.
104	SendOnchain {
105		input_vtxo_ids: Vec<VtxoId>,
106		/// Holds the arkoor-output (offboard input) vtxo.
107		arkoor_key_index: u32,
108		/// Holds the arkoor change. Must differ from `arkoor_key_index`: the
109		/// arkoor builder refuses a change output paying the destination.
110		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/// The phases of offboarding.
131///
132/// `SplitWithArkoor` and `ArkoorRegistrationRequired` are only reached from
133/// the `SendOnchain` kind; the `OffboardWhole` kind transitions directly
134/// from `Start` to `ReadyForOffboard`.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub enum Progress {
137	/// Inputs need locking, but we have selected VTXOs to offboard.
138	Start,
139	/// `SendOnchain` intermediate: We need to perform an arkoor to split the VTXOs so we can
140	/// offboard the exact amount requested.
141	SplitWithArkoor,
142	/// `SendOnchain` intermediate: arkoor done, yet to be registered with the server.
143	/// Both the offboard vtxos and the change are held locked until registration
144	/// succeeds; only then is the change released as spendable.
145	ArkoorRegistrationRequired {
146		offboard_vtxo_ids: Vec<VtxoId>,
147		change_vtxo_ids: Vec<VtxoId>,
148	},
149	/// VTXOs are locked, (potential) split is done, now we can start the actual offboard.
150	ReadyForOffboard {
151		/// In the case of `SendOnchain` this is the arkoor VTXOs we just created, in the case
152		/// of `OffboardWhole` this is the VTXOs we selected to offboard.
153		offboard_vtxo_ids: Vec<VtxoId>,
154		/// Set when we fell back here from [Progress::OffboardTxPrepared]
155		/// because the server's session disappeared before we could finish
156		/// it, and its tx wasn't visible on chain. If the fresh prepare then
157		/// gets rejected because the inputs are spent, the prior tx probably
158		/// did make it out after all, so we keep looking for it on chain
159		/// instead of failing the action.
160		#[serde(default)]
161		prior_txid: Option<Txid>,
162	},
163	/// Offboard tx built by the server and validated by us; our forfeits
164	/// are not signed yet — that happens inside the finish step, with
165	/// fresh nonces on every attempt, so that this checkpoint stays
166	/// value-deterministic across re-drives.
167	OffboardTxPrepared {
168		offboard_vtxo_ids: Vec<VtxoId>,
169		#[serde(with = "bitcoin_ext::serde::encodable")]
170		offboard_tx: Transaction,
171		/// The server's forfeit cosign nonces from the prepare response;
172		/// stable across prepare replays.
173		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	/// Offboard tx broadcast; waiting for confirmation.
183	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
193/// Outcome of a single confirmation check on an `AwaitingConfirmations` offboard.
194pub(crate) enum ConfirmationOutcome {
195	Confirmed,
196	Pending,
197	/// The tx hasn't been seen on chain for over
198	/// [Config::offboard_lost_tx_grace_period_secs](crate::Config::offboard_lost_tx_grace_period_secs).
199	///
200	/// We deliberately do NOT cancel the action and release its vtxos:
201	/// after finish they are forfeited to the server, so treating them
202	/// as spendable again would corrupt the wallet. The action parks
203	/// with an error and keeps re-checking the chain on every drive.
204	Lost,
205}
206
207/// User-level spec passed to [`start_offboard`] describing which
208/// flavour of offboard is being launched.
209pub enum StartOffboardSpec {
210	/// Forfeit whole VTXOs as-is. Caller picks the vtxos; fees are
211	/// deducted from the gross amount.
212	OffboardWhole { vtxos: Vec<WalletVtxo> },
213	/// Send a specific amount on-chain. The wallet picks inputs and
214	/// runs an arkoor first to produce an exact-sized vtxo.
215	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				// Reaching `AwaitingConfirmations` is the user-observable boundary
246				// (caller wants the txid back). Park here so the
247				// orchestration layer can read the persisted checkpoint
248				// without racing the executor past it.
249				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						// The forfeits only become valid once the offboard tx
279						// confirms (they spend one of its outputs), but the
280						// server holds the fully signed tx and can commit it
281						// at any time — so the inputs cannot be released.
282						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		// Park with backoff like the default, but surface the error: the
322		// user-facing offboard call drives UntilParkOrDone and should report
323		// what actually failed (e.g. the server being short on confirmed
324		// funds), not a generic "parked" message. The checkpoint still
325		// persists and the sync loop retries regardless.
326		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				// We can safely unlock our VTXOs.
347				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				// TODO: should we auto-exit here ?
353				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				// We already fell back here once from OffboardTxPrepared:
362				// our finish session had disappeared and its tx wasn't
363				// visible on chain. Now the fresh prepare got rejected too.
364				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					// The server rejected the request itself (e.g. the fee
371					// rate we committed to went stale), which it only does
372					// after checking the inputs spendable. That proves the
373					// prior session died unfinished and nothing of ours is
374					// forfeited, so retrying can never succeed and it is
375					// safe to cancel the offboard and release the vtxos.
376					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				// Any other rejection (typically: inputs already spent)
381				// makes it likeliest that the prior finish DID go through
382				// and our chain source simply lags the server's node. Keep
383				// looking for the prior tx; failing here would wrongly
384				// release forfeited vtxos as spendable.
385				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				// Arkoor are spendable at this point, it is safe to fail here on rejection
395				fail_offboard_movement(wallet, &self).await?;
396				Ok(Advance::Failed(error.into()))
397			},
398			Progress::OffboardTxPrepared { offboard_vtxo_ids, offboard_tx, .. } => {
399				// The server no longer holds a session for this offboard
400				// (finish sessions only survive the server's session
401				// timeout). Either our finish went through and we lost the
402				// response — then the tx is on chain and we adopt it — or
403				// the session expired unfinished, and it is safe to prepare
404				// a fresh one: the expired session's forfeits can never be
405				// completed since the server's secret nonces died with it.
406				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				// Park instead of advancing directly so that a repeated
422				// rejection can't spin hot, burning a fresh server session
423				// (and its UTXO locks) every round trip.
424				Ok(Advance::Park {
425					state,
426					wake_after: Some(CONFIRMATION_POLL_INTERVAL),
427					error: None,
428				})
429			},
430		}
431	}
432}
433
434/// Build a fresh [`Offboard`] in [`Progress::Start`]: pick inputs (for
435/// `SendOnchain`), derive the offboard key (for `SendOnchain`), validate fee/dust
436/// constraints, and lock the inputs under the new action id.
437///
438/// The executor persists the returned state. Idempotent under re-run
439/// only if no checkpoint exists yet for this offboard (the caller is
440/// responsible for the existence check).
441pub(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	// Duplicate inputs would break forfeit signing (one nonce per input) and
502	// are rejected by the server; catch them before we lock anything.
503	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
520/// Locks the VTXOs, ready for the next step which differs based on the [OffboardKind].
521async 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
542/// Split the inputs into an exact-sized offboard vtxo plus change and record the `SendOnchain`
543/// movement.
544async 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	// VTXO creation is deterministic and idempotent due to the previously derived keypairs.
562	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	// The server has marked our VTXOs as spent, so we must update accordingly.
577	// Both the offboard vtxo and the change are held under the action until
578	// the registration step registers their tx chains with the server; only
579	// then is the change released as spendable (the offboard vtxo stays
580	// locked until it is forfeited).
581	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	// Create the movement early since we just performed an operation.
593	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
602/// Registers the new arkoor VTXOs (both the offboard vtxos and the change)
603/// with the server, then releases the change as spendable. The offboard
604/// vtxos stay locked until they are forfeited.
605async 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	// Registration succeeded, so the change is safe to spend now.
618	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
624/// `ReadyForOffboard -> OffboardTxPrepared`: have the server build the
625/// offboard tx, validate it and record the movement (for `SendOnchain`
626/// it already exists from the arkoor split).
627///
628/// Server-side `prepare_offboard` is idempotent as long as we re-send the exact same request
629/// (inputs, amounts, fee rate): the server replays its pending session, returning the same
630/// unsigned tx and the same cosign nonces.
631async 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	// Ensure the request remains deterministic and thus reentrant by sorting the offboard inputs.
639	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	// Build the request, we can skip recalculating fees because the user already committed to a
652	// fee structure, the server will reject invalid fees so we can safely unlock our inputs if
653	// our numbers differ later on. This will fail the payment and the user can try again if they
654	// find the new fees acceptable.
655	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	// Finally, we can make the request; this is idempotent ONLY if our request is deterministic. If
674	// the server rejects this, we can safely unlock our funds.
675	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	// A replayed prepare returns the same cosign nonces, so this
693	// checkpoint is identical no matter how often the step re-runs.
694	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	// We can safely ignore the change in the movement because `SendOnchain` has already had a
700	// movement created for it.
701	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
712/// `OffboardTxPrepared -> ReadyForBroadcast`: sign our forfeits and trade
713/// them for the server-signed offboard tx, WITHOUT broadcasting it
714/// ([`broadcast_offboard`] does that).
715///
716/// The forfeits are signed here, with fresh nonces on every attempt:
717/// re-signing the same message with a new random nonce is safe, and
718/// keeping signatures out of the checkpoint keeps the prepared state
719/// value-deterministic across re-drives. A retry can thus carry different
720/// signatures than the attempt the server completed; the server replays
721/// its response by txid, so this is re-entrant while the session lives.
722/// Once the session is gone, the resulting rejection is recovered in
723/// `on_rejection`.
724async 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	// The txid pins everything except the witnesses, so checking every
769	// input carries one is all that remains to prove the server signed
770	// the tx. The signatures themselves can't be validated: the inputs
771	// spend the server's own wallet utxos, whose prevouts we don't have.
772	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
786/// Whether a server rejection of a prepare request proves the input
787/// vtxos were still spendable when the server processed it.
788///
789/// The server validates the request parameters (fee rate freshness,
790/// amounts, the address blocklist) only after its input spendability
791/// check, so these rejections can only be raised for unspent inputs.
792/// Matching is conservative: anything unrecognized returns false, so
793/// callers fall back to the behavior that is safe for spent inputs.
794fn rejection_proves_inputs_spendable(error: &AdvanceError) -> bool {
795	let AdvanceError::Server(status) = error else {
796		return false;
797	};
798	// TODO: We need to formalize server errors more, perhaps with a dedicated error code system.
799	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
805/// Check whether the offboard tx made it to the mempool or chain even
806/// though the server no longer holds a session for it (the server also
807/// broadcasts the tx itself after a successful finish). If so, return an
808/// [Progress::AwaitingConfirmations] adopting the broadcast tx.
809///
810/// Used by rejection recovery, so it must be re-entrant; it only reads
811/// the chain and (re-)uses the movement keyed by the action id.
812async 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
837/// `ReadyForBroadcast -> AwaitingConfirmations`: publish the signed offboard tx to chain.
838/// Idempotent: re-broadcasting a tx already in mempool/chain is a no-op.
839async 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
858/// `AwaitingConfirmations -> Done`: mark the forfeited vtxos as spent and
859/// finalise the movement. Only called once the caller has established that
860/// the tx has enough confirmations (or zero confs are required and the tx
861/// is in the mempool).
862async 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	// The vtxos MUST all be Spent before the executor sees Done: Done
872	// releases anything still locked by the action back to Spendable, and
873	// these vtxos are forfeited to the server. So a failure here has to
874	// propagate and retry the step rather than fall through. Spent is
875	// allowed as an old state so a re-driven settle is a no-op.
876	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
887/// Look up the current confirmation status for an offboard tx and
888/// collapse it to a `ConfirmationOutcome`.
889async 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
942/// Creates a movement for the offboard action based on the [OffboardKind].
943async 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
989/// Record the action's movement as failed before the action fails
990/// terminally; without this, `Advance::Failed` removes the checkpoint but
991/// leaves the movement pending forever.
992///
993/// The movement is keyed by the action id, so an attempt that had already
994/// created one (`SendOnchain` after its arkoor, `OffboardWhole` after
995/// prepare) finds it back; an attempt that hadn't gets a failed movement
996/// recording what it tried to do. Both make this re-entrant.
997async 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	// The balance didn't actually change: we only fail on paths where no
1006	// forfeit was signed and nothing was broadcast, so every vtxo the
1007	// action locked goes back to spendable.
1008	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}