Skip to main content

bark/
offboard.rs

1
2use anyhow::Context;
3use bitcoin::{Amount, SignedAmount, Transaction, Txid};
4use bitcoin::hashes::Hash;
5use bitcoin::hex::DisplayHex;
6use bitcoin::secp256k1::Keypair;
7use log::{info, trace, warn};
8
9use ark::{musig, ProtocolEncoding, Vtxo, VtxoPolicy};
10use ark::arkoor::ArkoorDestination;
11use ark::attestations::OffboardRequestAttestation;
12use ark::fees::{validate_and_subtract_fee_min_dust, VtxoFeeInfo};
13use ark::offboard::{OffboardForfeitContext, OffboardRequest};
14use ark::vtxo::{Full, VtxoRef};
15use bitcoin_ext::{BlockHeight, TxStatus, P2TR_DUST};
16use server_rpc::{protos, ServerConnection, TryFromBytes};
17
18use crate::movement::manager::OnDropStatus;
19use crate::{Wallet, WalletVtxo};
20use crate::movement::update::MovementUpdate;
21use crate::movement::{MovementDestination, MovementStatus};
22use crate::persist::models::PendingOffboard;
23use crate::subsystem::{OffboardMovement, Subsystem};
24use crate::vtxo::{VtxoState, VtxoStateKind};
25
26
27impl Wallet {
28	/// Checks pending offboard transactions for confirmation status.
29	///
30	/// - On confirmation with enough confs (or mempool with 0 required confs): finalize as successful.
31	/// - On `NotFound`: wait at least 1 hour before canceling, in case the chain backend is slow.
32	/// - On error (e.g. network drop): log and keep waiting — don't cancel due to transient failures.
33	pub async fn sync_pending_offboards(&self) -> anyhow::Result<()> {
34		let pending_offboards: Vec<PendingOffboard> = self.inner.db.get_pending_offboards().await?;
35
36		if pending_offboards.is_empty() {
37			return Ok(());
38		}
39
40		let current_height = self.inner.chain.tip().await?;
41		let required_confs = self.inner.config.offboard_required_confirmations;
42
43		trace!("Checking {} pending offboard transaction(s)", pending_offboards.len());
44
45		for pending in pending_offboards {
46			let status = self.inner.chain.tx_status(pending.offboard_txid).await;
47
48			match status {
49				Ok(TxStatus::Confirmed(block_ref)) => {
50					let confs = current_height - (block_ref.height - 1);
51					if confs < required_confs as BlockHeight {
52						trace!(
53							"Offboard tx {} has {}/{} confirmations, waiting...",
54							pending.offboard_txid, confs, required_confs,
55						);
56						continue;
57					}
58
59					info!(
60						"Offboard tx {} confirmed, finalizing movement {}",
61						pending.offboard_txid, pending.movement_id,
62					);
63
64					// Mark VTXOs as spent
65					for vtxo_id in &pending.vtxo_ids {
66						if let Err(e) = self.inner.db.update_vtxo_state_checked(
67							*vtxo_id,
68							VtxoState::Spent,
69							&[VtxoStateKind::Locked],
70						).await {
71							warn!("Failed to mark vtxo {} as spent: {:#}", vtxo_id, e);
72						}
73					}
74
75					// Finish the movement as successful
76					if let Err(e) = self.inner.movements.finish_movement(
77						pending.movement_id,
78						MovementStatus::Successful,
79					).await {
80						warn!("Failed to finish movement {}: {:#}", pending.movement_id, e);
81					}
82
83					self.inner.db.remove_pending_offboard(pending.movement_id).await?;
84				}
85				Ok(TxStatus::Mempool) => {
86					if required_confs == 0 {
87						info!(
88							"Offboard tx {} in mempool with 0 required confirmations, \
89							finalizing movement {}",
90							pending.offboard_txid, pending.movement_id,
91						);
92
93						// Mark VTXOs as spent
94						for vtxo_id in &pending.vtxo_ids {
95							if let Err(e) = self.inner.db.update_vtxo_state_checked(
96								*vtxo_id,
97								VtxoState::Spent,
98								&[VtxoStateKind::Locked],
99							).await {
100								warn!("Failed to mark vtxo {} as spent: {:#}", vtxo_id, e);
101							}
102						}
103
104						// Finish the movement as successful
105						if let Err(e) = self.inner.movements.finish_movement(
106							pending.movement_id,
107							MovementStatus::Successful,
108						).await {
109							warn!("Failed to finish movement {}: {:#}", pending.movement_id, e);
110						}
111
112						self.inner.db.remove_pending_offboard(pending.movement_id).await?;
113					} else {
114						trace!(
115							"Offboard tx {} still in mempool, waiting...",
116							pending.offboard_txid,
117						);
118					}
119				}
120				Ok(TxStatus::NotFound) => {
121					// Don't cancel immediately — the chain backend might be slow
122					// or temporarily out of sync. Wait at least 1 hour before
123					// treating the tx as truly lost.
124					let age = chrono::Local::now() - pending.created_at;
125					if age < chrono::Duration::hours(1) {
126						trace!(
127							"Offboard tx {} not found, but only {} minutes old — waiting...",
128							pending.offboard_txid, age.num_minutes(),
129						);
130						continue;
131					}
132
133					warn!(
134						"Offboard tx {} not found after {} minutes, canceling movement {}",
135						pending.offboard_txid, age.num_minutes(), pending.movement_id,
136					);
137
138					// Restore VTXOs to spendable
139					for vtxo_id in &pending.vtxo_ids {
140						if let Err(e) = self.inner.db.update_vtxo_state_checked(
141							*vtxo_id,
142							VtxoState::Spendable,
143							&[VtxoStateKind::Locked],
144						).await {
145							warn!("Failed to restore vtxo {} to spendable: {:#}", vtxo_id, e);
146						}
147					}
148
149					// Finish the movement as failed
150					if let Err(e) = self.inner.movements.finish_movement(
151						pending.movement_id,
152						MovementStatus::Failed,
153					).await {
154						warn!("Failed to fail movement {}: {:#}", pending.movement_id, e);
155					}
156
157					self.inner.db.remove_pending_offboard(pending.movement_id).await?;
158				}
159				Err(e) => {
160					warn!(
161						"Failed to check status of offboard tx {}: {:#}",
162						pending.offboard_txid, e,
163					);
164				}
165			}
166		}
167
168		Ok(())
169	}
170
171	async fn offboard_inner(
172		&self,
173		srv: &mut ServerConnection,
174		vtxos: &[impl AsRef<Vtxo<Full>>],
175		vtxo_keys: &[Keypair],
176		req: &OffboardRequest,
177	) -> anyhow::Result<Transaction> {
178		// Register VTXO transaction chains with server before offboarding
179		self.register_vtxo_transactions_with_server(&vtxos).await?;
180
181		let input_ids = vtxos.iter().map(|v| v.as_ref().id()).collect::<Vec<_>>();
182		let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
183			offboard: Some(req.into()),
184			input_vtxo_ids: input_ids.iter()
185				.map(|id| id.to_bytes().to_vec())
186				.collect(),
187			attestation: vtxo_keys.iter()
188				.map(|k| OffboardRequestAttestation::new(req, &input_ids, k).serialize())
189				.collect(),
190		}).await.context("prepare offboard request failed")?.into_inner();
191		let unsigned_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
192			&prep_resp.offboard_tx,
193		).with_context(|| format!(
194			"received invalid unsigned offboard tx from server: {}", prep_resp.offboard_tx.as_hex(),
195		))?;
196		let offboard_txid = unsigned_offboard_tx.compute_txid();
197		info!("Received unsigned offboard tx {} from server", offboard_txid);
198		let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
199			Ok(musig::PublicNonce::from_bytes(&n)
200				.context("received invalid public cosign nonce from server")?)
201		}).collect::<anyhow::Result<Vec<_>>>()?;
202
203		let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_offboard_tx);
204		ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
205
206		let sigs = ctx.user_sign_forfeits(&vtxo_keys, &forfeit_cosign_nonces);
207
208		let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
209			offboard_txid: offboard_txid.as_byte_array().to_vec(),
210			user_nonces: sigs.public_nonces.iter().map(|n| n.serialize().to_vec()).collect(),
211			partial_signatures: sigs.partial_signatures.iter()
212				.map(|s| s.serialize().to_vec())
213				.collect(),
214		}).await.context("error sending offboard forfeit signatures to server")?.into_inner();
215
216		let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
217			&finish_resp.signed_offboard_tx,
218		).with_context(|| format!(
219			"received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
220		))?;
221		if signed_offboard_tx.compute_txid() != offboard_txid {
222			bail!("Signed offboard tx received from server is different from \
223				unsigned tx we forfeited for: unsigned={}, signed={}",
224				prep_resp.offboard_tx.as_hex(), finish_resp.signed_offboard_tx.as_hex(),
225			);
226		}
227
228		// we don't accept the tx if our mempool doesn't accept it, it might be a double spend
229		self.inner.chain.broadcast_tx(&signed_offboard_tx).await.with_context(|| format!(
230			"error broadcasting offboard tx {} (tx={})",
231			offboard_txid, finish_resp.signed_offboard_tx.as_hex(),
232		))?;
233
234		Ok(signed_offboard_tx)
235	}
236
237	/// Send to an onchain address using your offchain balance
238	pub async fn send_onchain(
239		&self,
240		destination: bitcoin::Address,
241		amount: Amount,
242	) -> anyhow::Result<Txid> {
243		if amount < P2TR_DUST {
244			bail!("it doesn't make sense to send dust");
245		}
246
247		let (mut srv, ark) = self.require_server().await?;
248		let offboard_feerate = srv.offboard_feerate().await?;
249
250		let destination_spk = destination.script_pubkey();
251		let (vtxos, fee) = self.select_vtxos_to_cover_with_fee(amount, |a, v| {
252			ark.fees.offboard.calculate(&destination_spk, a, offboard_feerate, v)
253				.ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
254		}).await?;
255		let required_amount = amount + fee;
256
257		info!("We can only offboard whole VTXOs, so we will make an arkoor tx first...");
258
259		// this will be the key that holds the temporary vtxos we will offboard
260		let offboard_pubkey = self.derive_store_next_keypair().await
261			.context("failed to create new keypair")?.0;
262		let offboard_dest = ArkoorDestination {
263			total_amount: required_amount,
264			policy: VtxoPolicy::new_pubkey(offboard_pubkey.public_key()),
265		};
266		// Peek the change keypair without storing it. We only persist the
267		// index below if the arkoor actually produced a change vtxo.
268		let (change_keypair, change_key_index) = self.peek_next_keypair().await
269			.context("failed to derive arkoor change keypair")?;
270		let arkoor = self.create_checkpointed_arkoor_with_vtxos(
271			offboard_dest, vtxos.into_iter(), change_keypair,
272		).await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
273		if !arkoor.change.is_empty() {
274			self.inner.db.store_vtxo_key(change_key_index, change_keypair.public_key()).await
275				.context("failed to store arkoor change keypair")?;
276		}
277
278		self.store_spendable_vtxos(&arkoor.change).await
279			.context("error storing change VTXOs from preparatory arkoor")?;
280		self.store_locked_vtxos(&arkoor.created, None).await
281			.context("error storing new VTXOs (locked) from preparatory arkoor")?;
282		self.mark_vtxos_as_spent(&arkoor.inputs).await
283			.context("error marking used input VTXOs as spent")?;
284
285		let mut movement = self.inner.movements.new_guarded_movement_with_update(
286			Subsystem::OFFBOARD,
287			OffboardMovement::SendOnchain.to_string(),
288			OnDropStatus::Failed,
289			MovementUpdate::new()
290				.intended_balance(-amount.to_signed()?)
291				.effective_balance(-required_amount.to_signed()?)
292				.fee(fee)
293				.consumed_vtxos(&arkoor.inputs)
294				.produced_vtxos(&arkoor.change)
295				.metadata([(
296					"offboard_vtxos".into(),
297					serde_json::to_value(
298						arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
299					).expect("offboard_vtxos can serde"),
300				)])
301				.sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
302		).await?;
303		let state = VtxoState::Locked {
304			holder: Some(crate::vtxo::VtxoLockHolder::Movement { id: movement.id() }),
305		};
306		self.set_vtxo_states(&arkoor.created, &state, &[]).await
307			.context("error setting movement id on locked VTXOs")?;
308
309		// now perform the offboard
310		let vtxos = arkoor.created;
311
312		let req = OffboardRequest {
313			script_pubkey: destination_spk.clone(),
314			net_amount: amount,
315			deduct_fees_from_gross_amount: false,
316			fee_rate: offboard_feerate,
317		};
318		let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
319
320		let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
321			.context("error performing offboard")?;
322
323		movement.apply_update(MovementUpdate::new()
324			.metadata(OffboardMovement::metadata(&signed_offboard_tx))
325		).await.context("error updating movement")?;
326
327		if self.inner.config.offboard_required_confirmations == 0 {
328			// No confirmation required — mark VTXOs as spent and succeed immediately
329			for vtxo in &vtxos {
330				self.inner.db.update_vtxo_state_checked(
331					vtxo.id(),
332					VtxoState::Spent,
333					&[crate::vtxo::VtxoStateKind::Locked],
334				).await.context("error marking vtxo as spent")?;
335			}
336			movement.success().await
337				.context("error finishing movement")?;
338		} else {
339			// Store as pending offboard — don't mark success until confirmed on chain
340			let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
341			self.inner.db.store_pending_offboard(&PendingOffboard {
342				movement_id: movement.id(),
343				offboard_txid: signed_offboard_tx.compute_txid(),
344				offboard_tx: signed_offboard_tx.clone(),
345				vtxo_ids,
346				destination: destination.to_string(),
347				created_at: chrono::Local::now(),
348			}).await.context("error storing pending offboard")?;
349
350			// Disarm the guard so it doesn't auto-fail the movement on drop
351			movement.stop();
352		}
353
354		Ok(signed_offboard_tx.compute_txid())
355	}
356
357	async fn offboard(
358		&self,
359		vtxos: Vec<WalletVtxo>,
360		destination: bitcoin::Address,
361	) -> anyhow::Result<Txid> {
362		let (mut srv, ark) = self.require_server().await?;
363		let offboard_feerate = srv.offboard_feerate().await?;
364		let tip = self.inner.chain.tip().await?;
365
366		let destination_spk = destination.script_pubkey();
367		let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
368		let fee = ark.fees.offboard.calculate(
369			&destination_spk,
370			vtxos_amount,
371			offboard_feerate,
372			vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
373		).context("error calculating offboard fee")?;
374		let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
375		let vtxo_keys = {
376			let mut keys = Vec::with_capacity(vtxos.len());
377			for v in &vtxos {
378				keys.push(self.get_vtxo_key(v).await?);
379			}
380			keys
381		};
382
383		let req = OffboardRequest {
384			script_pubkey: destination_spk.clone(),
385			net_amount,
386			deduct_fees_from_gross_amount: true,
387			fee_rate: offboard_feerate,
388		};
389
390		// Hydrate the inputs to their full form: offboard_inner needs the
391		// genesis chain to register and forfeit them with the server.
392		let input_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
393		let full_inputs = self.inner.db.get_full_vtxos(&input_ids).await
394			.context("failed to hydrate offboard input vtxos")?;
395		let signed_offboard_tx = self.offboard_inner(&mut srv, &full_inputs, &vtxo_keys, &req).await
396			.context("error performing offboard")?;
397
398		// Lock VTXOs instead of marking them as spent
399		let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
400		let effective_amt = -SignedAmount::try_from(vtxos_amount)
401			.expect("can't have this many vtxo sats");
402		let destination_str = destination.to_string();
403		let movement_id = self.inner.movements.new_movement_with_update(
404			Subsystem::OFFBOARD,
405			OffboardMovement::Offboard.to_string(),
406			MovementUpdate::new()
407				.intended_balance(effective_amt)
408				.effective_balance(effective_amt)
409				.fee(fee)
410				.consumed_vtxos(&vtxos)
411				.sent_to([MovementDestination::bitcoin(destination, net_amount)])
412				.metadata(OffboardMovement::metadata(&signed_offboard_tx)),
413		).await?;
414
415		self.lock_vtxos(&vtxos, Some(crate::vtxo::VtxoLockHolder::Movement { id: movement_id })).await?;
416
417		if self.inner.config.offboard_required_confirmations == 0 {
418			// No confirmation required — mark VTXOs as spent and succeed immediately
419			for vtxo in &vtxos {
420				self.inner.db.update_vtxo_state_checked(
421					vtxo.vtxo_id(),
422					VtxoState::Spent,
423					&[crate::vtxo::VtxoStateKind::Locked],
424				).await.context("error marking vtxo as spent")?;
425			}
426			self.inner.movements.finish_movement(
427				movement_id,
428				MovementStatus::Successful,
429			).await.context("error finishing movement")?;
430		} else {
431			// Store as pending offboard — wait for on-chain confirmation
432			self.inner.db.store_pending_offboard(&PendingOffboard {
433				movement_id,
434				offboard_txid: signed_offboard_tx.compute_txid(),
435				offboard_tx: signed_offboard_tx.clone(),
436				vtxo_ids,
437				destination: destination_str,
438				created_at: chrono::Local::now(),
439			}).await.context("error storing pending offboard")?
440		}
441
442		Ok(signed_offboard_tx.compute_txid())
443	}
444
445	/// Offboard all VTXOs to a given [bitcoin::Address].
446	pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
447		let input_vtxos = self.spendable_vtxos().await?;
448		Ok(self.offboard(input_vtxos, address).await?)
449	}
450
451	/// Offboard the given VTXOs to a given [bitcoin::Address].
452	pub async fn offboard_vtxos<V: VtxoRef>(
453		&self,
454		vtxos: impl IntoIterator<Item = V>,
455		address: bitcoin::Address,
456	) -> anyhow::Result<Txid> {
457		let mut input_vtxos = vec![];
458		for v in vtxos {
459			let id = v.vtxo_id();
460			let vtxo = match self.inner.db.get_wallet_vtxo(id).await? {
461				Some(vtxo) => vtxo,
462				_ => bail!("cannot find requested vtxo: {}", id),
463			};
464			input_vtxos.push(vtxo);
465		}
466
467		Ok(self.offboard(input_vtxos, address).await?)
468	}
469}