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		let arkoor = self.create_checkpointed_arkoor_with_vtxos(offboard_dest, vtxos.into_iter())
267			.await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
268
269		self.store_spendable_vtxos(&arkoor.change).await
270			.context("error storing change VTXOs from preparatory arkoor")?;
271		self.store_locked_vtxos(&arkoor.created, None).await
272			.context("error storing new VTXOs (locked) from preparatory arkoor")?;
273		self.mark_vtxos_as_spent(&arkoor.inputs).await
274			.context("error marking used input VTXOs as spent")?;
275
276		let mut movement = self.inner.movements.new_guarded_movement_with_update(
277			Subsystem::OFFBOARD,
278			OffboardMovement::SendOnchain.to_string(),
279			OnDropStatus::Failed,
280			MovementUpdate::new()
281				.intended_balance(-amount.to_signed()?)
282				.effective_balance(-required_amount.to_signed()?)
283				.fee(fee)
284				.consumed_vtxos(&arkoor.inputs)
285				.produced_vtxos(&arkoor.change)
286				.metadata([(
287					"offboard_vtxos".into(),
288					serde_json::to_value(
289						arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
290					).expect("offboard_vtxos can serde"),
291				)])
292				.sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
293		).await?;
294		let state = VtxoState::Locked {
295			holder: Some(crate::vtxo::VtxoLockHolder::Movement { id: movement.id() }),
296		};
297		self.set_vtxo_states(&arkoor.created, &state, &[]).await
298			.context("error setting movement id on locked VTXOs")?;
299
300		// now perform the offboard
301		let vtxos = arkoor.created;
302
303		let req = OffboardRequest {
304			script_pubkey: destination_spk.clone(),
305			net_amount: amount,
306			deduct_fees_from_gross_amount: false,
307			fee_rate: offboard_feerate,
308		};
309		let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
310
311		let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
312			.context("error performing offboard")?;
313
314		movement.apply_update(MovementUpdate::new()
315			.metadata(OffboardMovement::metadata(&signed_offboard_tx))
316		).await.context("error updating movement")?;
317
318		if self.inner.config.offboard_required_confirmations == 0 {
319			// No confirmation required — mark VTXOs as spent and succeed immediately
320			for vtxo in &vtxos {
321				self.inner.db.update_vtxo_state_checked(
322					vtxo.id(),
323					VtxoState::Spent,
324					&[crate::vtxo::VtxoStateKind::Locked],
325				).await.context("error marking vtxo as spent")?;
326			}
327			movement.success().await
328				.context("error finishing movement")?;
329		} else {
330			// Store as pending offboard — don't mark success until confirmed on chain
331			let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
332			self.inner.db.store_pending_offboard(&PendingOffboard {
333				movement_id: movement.id(),
334				offboard_txid: signed_offboard_tx.compute_txid(),
335				offboard_tx: signed_offboard_tx.clone(),
336				vtxo_ids,
337				destination: destination.to_string(),
338				created_at: chrono::Local::now(),
339			}).await.context("error storing pending offboard")?;
340
341			// Disarm the guard so it doesn't auto-fail the movement on drop
342			movement.stop();
343		}
344
345		Ok(signed_offboard_tx.compute_txid())
346	}
347
348	async fn offboard(
349		&self,
350		vtxos: Vec<WalletVtxo>,
351		destination: bitcoin::Address,
352	) -> anyhow::Result<Txid> {
353		let (mut srv, ark) = self.require_server().await?;
354		let offboard_feerate = srv.offboard_feerate().await?;
355		let tip = self.inner.chain.tip().await?;
356
357		let destination_spk = destination.script_pubkey();
358		let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
359		let fee = ark.fees.offboard.calculate(
360			&destination_spk,
361			vtxos_amount,
362			offboard_feerate,
363			vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
364		).context("error calculating offboard fee")?;
365		let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
366		let vtxo_keys = {
367			let mut keys = Vec::with_capacity(vtxos.len());
368			for v in &vtxos {
369				keys.push(self.get_vtxo_key(v).await?);
370			}
371			keys
372		};
373
374		let req = OffboardRequest {
375			script_pubkey: destination_spk.clone(),
376			net_amount,
377			deduct_fees_from_gross_amount: true,
378			fee_rate: offboard_feerate,
379		};
380
381		// Hydrate the inputs to their full form: offboard_inner needs the
382		// genesis chain to register and forfeit them with the server.
383		let input_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
384		let full_inputs = self.inner.db.get_full_vtxos(&input_ids).await
385			.context("failed to hydrate offboard input vtxos")?;
386		let signed_offboard_tx = self.offboard_inner(&mut srv, &full_inputs, &vtxo_keys, &req).await
387			.context("error performing offboard")?;
388
389		// Lock VTXOs instead of marking them as spent
390		let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
391		let effective_amt = -SignedAmount::try_from(vtxos_amount)
392			.expect("can't have this many vtxo sats");
393		let destination_str = destination.to_string();
394		let movement_id = self.inner.movements.new_movement_with_update(
395			Subsystem::OFFBOARD,
396			OffboardMovement::Offboard.to_string(),
397			MovementUpdate::new()
398				.intended_balance(effective_amt)
399				.effective_balance(effective_amt)
400				.fee(fee)
401				.consumed_vtxos(&vtxos)
402				.sent_to([MovementDestination::bitcoin(destination, net_amount)])
403				.metadata(OffboardMovement::metadata(&signed_offboard_tx)),
404		).await?;
405
406		self.lock_vtxos(&vtxos, Some(crate::vtxo::VtxoLockHolder::Movement { id: movement_id })).await?;
407
408		if self.inner.config.offboard_required_confirmations == 0 {
409			// No confirmation required — mark VTXOs as spent and succeed immediately
410			for vtxo in &vtxos {
411				self.inner.db.update_vtxo_state_checked(
412					vtxo.vtxo_id(),
413					VtxoState::Spent,
414					&[crate::vtxo::VtxoStateKind::Locked],
415				).await.context("error marking vtxo as spent")?;
416			}
417			self.inner.movements.finish_movement(
418				movement_id,
419				MovementStatus::Successful,
420			).await.context("error finishing movement")?;
421		} else {
422			// Store as pending offboard — wait for on-chain confirmation
423			self.inner.db.store_pending_offboard(&PendingOffboard {
424				movement_id,
425				offboard_txid: signed_offboard_tx.compute_txid(),
426				offboard_tx: signed_offboard_tx.clone(),
427				vtxo_ids,
428				destination: destination_str,
429				created_at: chrono::Local::now(),
430			}).await.context("error storing pending offboard")?
431		}
432
433		Ok(signed_offboard_tx.compute_txid())
434	}
435
436	/// Offboard all VTXOs to a given [bitcoin::Address].
437	pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
438		let input_vtxos = self.spendable_vtxos().await?;
439		Ok(self.offboard(input_vtxos, address).await?)
440	}
441
442	/// Offboard the given VTXOs to a given [bitcoin::Address].
443	pub async fn offboard_vtxos<V: VtxoRef>(
444		&self,
445		vtxos: impl IntoIterator<Item = V>,
446		address: bitcoin::Address,
447	) -> anyhow::Result<Txid> {
448		let mut input_vtxos = vec![];
449		for v in vtxos {
450			let id = v.vtxo_id();
451			let vtxo = match self.inner.db.get_wallet_vtxo(id).await? {
452				Some(vtxo) => vtxo,
453				_ => bail!("cannot find requested vtxo: {}", id),
454			};
455			input_vtxos.push(vtxo);
456		}
457
458		Ok(self.offboard(input_vtxos, address).await?)
459	}
460}