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, 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
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		/// Change piece amounts fixed at start. `None` when persisted by a
112		/// pre-split bark, meaning one whole change output.
113		#[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/// The phases of offboarding.
135///
136/// `SplitWithArkoor` and `ArkoorRegistrationRequired` are only reached from
137/// the `SendOnchain` kind; the `OffboardWhole` kind transitions directly
138/// from `Start` to `ReadyForOffboard`.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub enum Progress {
141	/// Inputs need locking, but we have selected VTXOs to offboard.
142	Start,
143	/// `SendOnchain` intermediate: We need to perform an arkoor to split the VTXOs so we can
144	/// offboard the exact amount requested.
145	SplitWithArkoor,
146	/// `SendOnchain` intermediate: arkoor done, yet to be registered with the server.
147	/// Both the offboard vtxos and the change are held locked until registration
148	/// succeeds; only then is the change released as spendable.
149	ArkoorRegistrationRequired {
150		offboard_vtxo_ids: Vec<VtxoId>,
151		change_vtxo_ids: Vec<VtxoId>,
152	},
153	/// VTXOs are locked, (potential) split is done, now we can start the actual offboard.
154	ReadyForOffboard {
155		/// In the case of `SendOnchain` this is the arkoor VTXOs we just created, in the case
156		/// of `OffboardWhole` this is the VTXOs we selected to offboard.
157		offboard_vtxo_ids: Vec<VtxoId>,
158		/// Set when we fell back here from [Progress::OffboardTxPrepared]
159		/// because the server's session disappeared before we could finish
160		/// it, and its tx wasn't visible on chain. If the fresh prepare then
161		/// gets rejected because the inputs are spent, the prior tx probably
162		/// did make it out after all, so we keep looking for it on chain
163		/// instead of failing the action.
164		#[serde(default)]
165		prior_txid: Option<Txid>,
166	},
167	/// Offboard tx built by the server and validated by us; our forfeits
168	/// are not signed yet — that happens inside the finish step, with
169	/// fresh nonces on every attempt, so that this checkpoint stays
170	/// value-deterministic across re-drives.
171	OffboardTxPrepared {
172		offboard_vtxo_ids: Vec<VtxoId>,
173		#[serde(with = "bitcoin_ext::serde::encodable")]
174		offboard_tx: Transaction,
175		/// The server's forfeit cosign nonces from the prepare response;
176		/// stable across prepare replays.
177		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	/// Offboard tx broadcast; waiting for confirmation.
187	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
197/// Outcome of a single confirmation check on an `AwaitingConfirmations` offboard.
198pub(crate) enum ConfirmationOutcome {
199	Confirmed,
200	Pending,
201	/// The tx hasn't been seen on chain for over
202	/// [Config::offboard_lost_tx_grace_period_secs](crate::Config::offboard_lost_tx_grace_period_secs).
203	///
204	/// We deliberately do NOT cancel the action and release its vtxos:
205	/// after finish they are forfeited to the server, so treating them
206	/// as spendable again would corrupt the wallet. The action parks
207	/// with an error and keeps re-checking the chain on every drive.
208	Lost,
209}
210
211/// User-level spec passed to [`start_offboard`] describing which
212/// flavour of offboard is being launched.
213pub enum StartOffboardSpec {
214	/// Forfeit whole VTXOs as-is. Caller picks the vtxos; fees are
215	/// deducted from the gross amount.
216	OffboardWhole { vtxos: Vec<WalletVtxo> },
217	/// Send a specific amount on-chain. The wallet picks inputs and
218	/// runs an arkoor first to produce an exact-sized vtxo.
219	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				// Reaching `AwaitingConfirmations` is the user-observable boundary
250				// (caller wants the txid back). Park here so the
251				// orchestration layer can read the persisted checkpoint
252				// without racing the executor past it.
253				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						// The forfeits only become valid once the offboard tx
283						// confirms (they spend one of its outputs), but the
284						// server holds the fully signed tx and can commit it
285						// at any time — so the inputs cannot be released.
286						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		// Park with backoff like the default, but surface the error: the
326		// user-facing offboard call drives UntilParkOrDone and should report
327		// what actually failed (e.g. the server being short on confirmed
328		// funds), not a generic "parked" message. The checkpoint still
329		// persists and the sync loop retries regardless.
330		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				// We can safely unlock our VTXOs.
351				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				// TODO: should we auto-exit here ?
357				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				// We already fell back here once from OffboardTxPrepared:
366				// our finish session had disappeared and its tx wasn't
367				// visible on chain. Now the fresh prepare got rejected too.
368				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					// The server rejected the request itself (e.g. the fee
375					// rate we committed to went stale), which it only does
376					// after checking the inputs spendable. That proves the
377					// prior session died unfinished and nothing of ours is
378					// forfeited, so retrying can never succeed and it is
379					// safe to cancel the offboard and release the vtxos.
380					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				// Any other rejection (typically: inputs already spent)
385				// makes it likeliest that the prior finish DID go through
386				// and our chain source simply lags the server's node. Keep
387				// looking for the prior tx; failing here would wrongly
388				// release forfeited vtxos as spendable.
389				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				// Arkoor are spendable at this point, it is safe to fail here on rejection
399				fail_offboard_movement(wallet, &self).await?;
400				Ok(Advance::Failed(error.into()))
401			},
402			Progress::OffboardTxPrepared { offboard_vtxo_ids, offboard_tx, .. } => {
403				// The server no longer holds a session for this offboard
404				// (finish sessions only survive the server's session
405				// timeout). Either our finish went through and we lost the
406				// response — then the tx is on chain and we adopt it — or
407				// the session expired unfinished, and it is safe to prepare
408				// a fresh one: the expired session's forfeits can never be
409				// completed since the server's secret nonces died with it.
410				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				// Park instead of advancing directly so that a repeated
426				// rejection can't spin hot, burning a fresh server session
427				// (and its UTXO locks) every round trip.
428				Ok(Advance::Park {
429					state,
430					wake_after: Some(CONFIRMATION_POLL_INTERVAL),
431					error: None,
432				})
433			},
434		}
435	}
436}
437
438/// Build a fresh [`Offboard`] in [`Progress::Start`]: pick inputs (for
439/// `SendOnchain`), derive the offboard key (for `SendOnchain`), validate fee/dust
440/// constraints, and lock the inputs under the new action id.
441///
442/// The executor persists the returned state. Idempotent under re-run
443/// only if no checkpoint exists yet for this offboard (the caller is
444/// responsible for the existence check).
445pub(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	// Duplicate inputs would break forfeit signing (one nonce per input) and
513	// are rejected by the server; catch them before we lock anything.
514	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
531/// Locks the VTXOs, ready for the next step which differs based on the [OffboardKind].
532async 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
553/// Split the inputs into an exact-sized offboard vtxo plus change and record the `SendOnchain`
554/// movement.
555async 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	// VTXO creation is deterministic and idempotent due to the previously derived keypairs.
573	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	// The server has marked our VTXOs as spent, so we must update accordingly.
590	// Both the offboard vtxo and the change are held under the action until
591	// the registration step registers their tx chains with the server; only
592	// then is the change released as spendable (the offboard vtxo stays
593	// locked until it is forfeited).
594	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	// Create the movement early since we just performed an operation.
606	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
615/// Registers the new arkoor VTXOs (both the offboard vtxos and the change)
616/// with the server, then releases the change as spendable. The offboard
617/// vtxos stay locked until they are forfeited.
618async 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	// Registration succeeded, so the change is safe to spend now.
632	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
640/// `ReadyForOffboard -> OffboardTxPrepared`: have the server build the
641/// offboard tx, validate it and record the movement (for `SendOnchain`
642/// it already exists from the arkoor split).
643///
644/// Server-side `prepare_offboard` is idempotent as long as we re-send the exact same request
645/// (inputs, amounts, fee rate): the server replays its pending session, returning the same
646/// unsigned tx and the same cosign nonces.
647async 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	// Ensure the request remains deterministic and thus reentrant by sorting the offboard inputs.
655	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	// Build the request, we can skip recalculating fees because the user already committed to a
668	// fee structure, the server will reject invalid fees so we can safely unlock our inputs if
669	// our numbers differ later on. This will fail the payment and the user can try again if they
670	// find the new fees acceptable.
671	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	// Finally, we can make the request; this is idempotent ONLY if our request is deterministic. If
690	// the server rejects this, we can safely unlock our funds.
691	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	// A replayed prepare returns the same cosign nonces, so this
710	// checkpoint is identical no matter how often the step re-runs.
711	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	// Signing needs one nonce per input, so a response carrying any other
717	// number is unusable. Reject it here, before the checkpoint below makes
718	// it durable: a persisted mismatch would fail this action on every
719	// re-drive, and taking the sync loop's offboard step down with it stops
720	// the steps that run after it from ever being reached.
721	OffboardForfeitError::check_count(
722		"forfeit cosign nonces", vtxos.len(), forfeit_cosign_nonces.len(),
723	).context("invalid prepare_offboard response from server")?;
724
725	// We can safely ignore the change in the movement because `SendOnchain` has already had a
726	// movement created for it.
727	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
738/// `OffboardTxPrepared -> ReadyForBroadcast`: sign our forfeits and trade
739/// them for the server-signed offboard tx, WITHOUT broadcasting it
740/// ([`broadcast_offboard`] does that).
741///
742/// The forfeits are signed here, with fresh nonces on every attempt:
743/// re-signing the same message with a new random nonce is safe, and
744/// keeping signatures out of the checkpoint keeps the prepared state
745/// value-deterministic across re-drives. A retry can thus carry different
746/// signatures than the attempt the server completed; the server replays
747/// its response by txid, so this is re-entrant while the session lives.
748/// Once the session is gone, the resulting rejection is recovered in
749/// `on_rejection`.
750async 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	// `prepare_offboard` refuses to checkpoint a mismatched nonce count, so
772	// only a checkpoint written by a bark that predates that check can land
773	// here. Fail the drive rather than panic: a panic would take down the
774	// sync task and every step queued behind this one.
775	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	// The txid pins everything except the witnesses, so checking every
801	// input carries one is all that remains to prove the server signed
802	// the tx. The signatures themselves can't be validated: the inputs
803	// spend the server's own wallet utxos, whose prevouts we don't have.
804	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
818/// Whether a server rejection of a prepare request proves the input
819/// vtxos were still spendable when the server processed it.
820///
821/// The server validates the request parameters (fee rate freshness,
822/// amounts, the address blocklist) only after its input spendability
823/// check, so these rejections can only be raised for unspent inputs.
824/// Matching is conservative: anything unrecognized returns false, so
825/// callers fall back to the behavior that is safe for spent inputs.
826fn rejection_proves_inputs_spendable(error: &AdvanceError) -> bool {
827	let AdvanceError::Server(status) = error else {
828		return false;
829	};
830	// TODO: We need to formalize server errors more, perhaps with a dedicated error code system.
831	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
837/// Check whether the offboard tx made it to the mempool or chain even
838/// though the server no longer holds a session for it (the server also
839/// broadcasts the tx itself after a successful finish). If so, return an
840/// [Progress::AwaitingConfirmations] adopting the broadcast tx.
841///
842/// Used by rejection recovery, so it must be re-entrant; it only reads
843/// the chain and (re-)uses the movement keyed by the action id.
844async 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
869/// `ReadyForBroadcast -> AwaitingConfirmations`: publish the signed offboard tx to chain.
870/// Idempotent: re-broadcasting a tx already in mempool/chain is a no-op.
871async 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
890/// `AwaitingConfirmations -> Done`: mark the forfeited vtxos as spent and
891/// finalise the movement. Only called once the caller has established that
892/// the tx has enough confirmations (or zero confs are required and the tx
893/// is in the mempool).
894async 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	// The vtxos MUST all be Spent before the executor sees Done: Done
904	// releases anything still locked by the action back to Spendable, and
905	// these vtxos are forfeited to the server. So a failure here has to
906	// propagate and retry the step rather than fall through. Spent is
907	// allowed as an old state so a re-driven settle is a no-op.
908	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
919/// Look up the current confirmation status for an offboard tx and
920/// collapse it to a `ConfirmationOutcome`.
921async 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
974/// Creates a movement for the offboard action based on the [OffboardKind].
975async 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
1021/// Record the action's movement as failed before the action fails
1022/// terminally; without this, `Advance::Failed` removes the checkpoint but
1023/// leaves the movement pending forever.
1024///
1025/// The movement is keyed by the action id, so an attempt that had already
1026/// created one (`SendOnchain` after its arkoor, `OffboardWhole` after
1027/// prepare) finds it back; an attempt that hadn't gets a failed movement
1028/// recording what it tried to do. Both make this re-entrant.
1029async 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	// The balance didn't actually change: we only fail on paths where no
1038	// forfeit was signed and nothing was broadcast, so every vtxo the
1039	// action locked goes back to spendable.
1040	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}