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;
8
9use ark::{musig, Vtxo, VtxoPolicy};
10use ark::arkoor::ArkoorDestination;
11use ark::challenges::OffboardRequestChallenge;
12use ark::fees::{validate_and_subtract_fee_min_dust, VtxoFeeInfo};
13use ark::offboard::{OffboardForfeitContext, OffboardRequest};
14use ark::vtxo::{Full, VtxoRef};
15use bitcoin_ext::P2TR_DUST;
16use server_rpc::{protos, ServerConnection, TryFromBytes};
17
18use crate::movement::manager::OnDropStatus;
19use crate::vtxo::VtxoState;
20use crate::{Wallet, WalletVtxo};
21use crate::movement::update::MovementUpdate;
22use crate::movement::{MovementDestination, MovementStatus};
23use crate::persist::models::PendingOffboard;
24use crate::subsystem::{OffboardMovement, Subsystem};
25
26
27impl Wallet {
28	async fn offboard_inner(
29		&self,
30		srv: &mut ServerConnection,
31		vtxos: &[impl AsRef<Vtxo<Full>>],
32		vtxo_keys: &[Keypair],
33		req: &OffboardRequest,
34	) -> anyhow::Result<Transaction> {
35		// Register VTXOs with server before offboarding
36		self.register_vtxos_with_server(&vtxos).await?;
37
38		let challenge = OffboardRequestChallenge::new(req, vtxos.iter().map(|v| v.as_ref().id()));
39		let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
40			offboard: Some(req.into()),
41			input_vtxo_ids: vtxos.iter()
42				.map(|v| v.as_ref().id().to_bytes().to_vec())
43				.collect(),
44			input_vtxo_ownership_proofs: vtxo_keys.iter()
45				.map(|k| challenge.sign_with(k).serialize().to_vec())
46				.collect(),
47		}).await.context("prepare offboard request failed")?.into_inner();
48		let unsigned_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
49			&prep_resp.offboard_tx,
50		).with_context(|| format!(
51			"received invalid unsigned offboard tx from server: {}", prep_resp.offboard_tx.as_hex(),
52		))?;
53		let offboard_txid = unsigned_offboard_tx.compute_txid();
54		info!("Received unsigned offboard tx {} from server", offboard_txid);
55		let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
56			Ok(musig::PublicNonce::from_bytes(&n)
57				.context("received invalid public cosign nonce from server")?)
58		}).collect::<anyhow::Result<Vec<_>>>()?;
59
60		let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_offboard_tx);
61		ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
62
63		let sigs = ctx.user_sign_forfeits(&vtxo_keys, &forfeit_cosign_nonces);
64
65		let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
66			offboard_txid: offboard_txid.as_byte_array().to_vec(),
67			user_nonces: sigs.public_nonces.iter().map(|n| n.serialize().to_vec()).collect(),
68			partial_signatures: sigs.partial_signatures.iter()
69				.map(|s| s.serialize().to_vec())
70				.collect(),
71		}).await.context("error sending offboard forfeit signatures to server")?.into_inner();
72
73		let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
74			&finish_resp.signed_offboard_tx,
75		).with_context(|| format!(
76			"received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
77		))?;
78		if signed_offboard_tx.compute_txid() != offboard_txid {
79			bail!("Signed offboard tx received from server is different from \
80				unsigned tx we forfeited for: unsigned={}, signed={}",
81				prep_resp.offboard_tx.as_hex(), finish_resp.signed_offboard_tx.as_hex(),
82			);
83		}
84
85		// we don't accept the tx if our mempool doesn't accept it, it might be a double spend
86		self.chain.broadcast_tx(&signed_offboard_tx).await.with_context(|| format!(
87			"error broadcasting offboard tx {} (tx={})",
88			offboard_txid, finish_resp.signed_offboard_tx.as_hex(),
89		))?;
90
91		Ok(signed_offboard_tx)
92	}
93
94	/// Send to an onchain address using your offchain balance
95	pub async fn send_onchain(
96		&self,
97		destination: bitcoin::Address,
98		amount: Amount,
99	) -> anyhow::Result<Txid> {
100		if amount < P2TR_DUST {
101			bail!("it doesn't make sense to send dust");
102		}
103
104		let (mut srv, ark) = self.require_server().await?;
105
106		let destination_spk = destination.script_pubkey();
107		let (vtxos, fee) = self.select_vtxos_to_cover_with_fee(amount, |a, v| {
108			ark.fees.offboard.calculate(&destination_spk, a, ark.offboard_feerate, v)
109				.ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
110		}).await?;
111		let required_amount = amount + fee;
112
113		info!("We can only offboard whole VTXOs, so we will make an arkoor tx first...");
114
115		// this will be the key that holds the temporary vtxos we will offboard
116		let offboard_pubkey = self.derive_store_next_keypair().await
117			.context("failed to create new keypair")?.0;
118		let change_pubkey = self.derive_store_next_keypair().await
119			.context("failed to create new keypair")?.0;
120		let offboard_dest = ArkoorDestination {
121			total_amount: required_amount,
122			policy: VtxoPolicy::new_pubkey(offboard_pubkey.public_key()),
123		};
124		let arkoor = self.create_checkpointed_arkoor_with_vtxos(
125			offboard_dest,
126			change_pubkey.public_key(),
127			vtxos,
128		).await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
129
130		self.store_spendable_vtxos(&arkoor.change).await
131			.context("error storing change VTXOs from preparatory arkoor")?;
132		self.store_locked_vtxos(&arkoor.created, None).await
133			.context("error storing new VTXOs (locked) from preparatory arkoor")?;
134		self.mark_vtxos_as_spent(&arkoor.inputs).await
135			.context("error marking used input VTXOs as spent")?;
136
137		let mut movement = self.movements.new_guarded_movement_with_update(
138			Subsystem::OFFBOARD,
139			OffboardMovement::SendOnchain.to_string(),
140			OnDropStatus::Failed,
141			MovementUpdate::new()
142				.intended_balance(-amount.to_signed()?)
143				.effective_balance(-required_amount.to_signed()?)
144				.fee(fee)
145				.consumed_vtxos(&arkoor.inputs)
146				.produced_vtxos(&arkoor.change)
147				.metadata([(
148					"offboard_vtxos".into(),
149					serde_json::to_value(
150						arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
151					).expect("offboard_vtxos can serde"),
152				)])
153				.sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
154		).await?;
155		let state = VtxoState::Locked { movement_id: Some(movement.id()) };
156		self.set_vtxo_states(&arkoor.created, &state, &[]).await
157			.context("error setting movement id on locked VTXOs")?;
158
159		// now perform the offboard
160		let vtxos = arkoor.created;
161
162		let req = OffboardRequest {
163			script_pubkey: destination_spk.clone(),
164			net_amount: amount,
165			deduct_fees_from_gross_amount: false,
166			fee_rate: ark.offboard_feerate,
167		};
168		let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
169
170		let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
171			.context("error performing offboard")?;
172
173		movement.apply_update(MovementUpdate::new()
174			.metadata(OffboardMovement::metadata(&signed_offboard_tx))
175		).await.context("error updating movement")?;
176
177		if self.config.offboard_required_confirmations == 0 {
178			// No confirmation required — mark VTXOs as spent and succeed immediately
179			for vtxo in &vtxos {
180				self.db.update_vtxo_state_checked(
181					vtxo.id(),
182					VtxoState::Spent,
183					&[crate::vtxo::VtxoStateKind::Locked],
184				).await.context("error marking vtxo as spent")?;
185			}
186			movement.success().await
187				.context("error finishing movement")?;
188		} else {
189			// Store as pending offboard — don't mark success until confirmed on chain
190			let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
191			self.db.store_pending_offboard(&PendingOffboard {
192				movement_id: movement.id(),
193				offboard_txid: signed_offboard_tx.compute_txid(),
194				offboard_tx: signed_offboard_tx.clone(),
195				vtxo_ids,
196				destination: destination.to_string(),
197				created_at: chrono::Local::now(),
198			}).await.context("error storing pending offboard")?;
199
200			// Disarm the guard so it doesn't auto-fail the movement on drop
201			movement.stop();
202		}
203
204		Ok(signed_offboard_tx.compute_txid())
205	}
206
207	async fn offboard(
208		&self,
209		vtxos: Vec<WalletVtxo>,
210		destination: bitcoin::Address,
211	) -> anyhow::Result<Txid> {
212		let (mut srv, ark) = self.require_server().await?;
213		let tip = self.chain.tip().await?;
214
215		let destination_spk = destination.script_pubkey();
216		let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
217		let fee = ark.fees.offboard.calculate(
218			&destination_spk,
219			vtxos_amount,
220			ark.offboard_feerate,
221			vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
222		).context("error calculating offboard fee")?;
223		let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
224		let vtxo_keys = {
225			let mut keys = Vec::with_capacity(vtxos.len());
226			for v in &vtxos {
227				keys.push(self.get_vtxo_key(v).await?);
228			}
229			keys
230		};
231
232		let req = OffboardRequest {
233			script_pubkey: destination_spk.clone(),
234			net_amount,
235			deduct_fees_from_gross_amount: true,
236			fee_rate: ark.offboard_feerate,
237		};
238
239		let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
240			.context("error performing offboard")?;
241
242		// Lock VTXOs instead of marking them as spent
243		let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
244		let effective_amt = -SignedAmount::try_from(vtxos_amount)
245			.expect("can't have this many vtxo sats");
246		let destination_str = destination.to_string();
247		let movement_id = self.movements.new_movement_with_update(
248			Subsystem::OFFBOARD,
249			OffboardMovement::Offboard.to_string(),
250			MovementUpdate::new()
251				.intended_balance(effective_amt)
252				.effective_balance(effective_amt)
253				.fee(fee)
254				.consumed_vtxos(&vtxos)
255				.sent_to([MovementDestination::bitcoin(destination, net_amount)])
256				.metadata(OffboardMovement::metadata(&signed_offboard_tx)),
257		).await?;
258
259		self.lock_vtxos(&vtxos, Some(movement_id)).await?;
260
261		if self.config.offboard_required_confirmations == 0 {
262			// No confirmation required — mark VTXOs as spent and succeed immediately
263			for vtxo in &vtxos {
264				self.db.update_vtxo_state_checked(
265					vtxo.vtxo_id(),
266					VtxoState::Spent,
267					&[crate::vtxo::VtxoStateKind::Locked],
268				).await.context("error marking vtxo as spent")?;
269			}
270			self.movements.finish_movement(
271				movement_id,
272				MovementStatus::Successful,
273			).await.context("error finishing movement")?;
274		} else {
275			// Store as pending offboard — wait for on-chain confirmation
276			self.db.store_pending_offboard(&PendingOffboard {
277				movement_id,
278				offboard_txid: signed_offboard_tx.compute_txid(),
279				offboard_tx: signed_offboard_tx.clone(),
280				vtxo_ids,
281				destination: destination_str,
282				created_at: chrono::Local::now(),
283			}).await.context("error storing pending offboard")?
284		}
285
286		Ok(signed_offboard_tx.compute_txid())
287	}
288
289	/// Offboard all VTXOs to a given [bitcoin::Address].
290	pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
291		let input_vtxos = self.spendable_vtxos().await?;
292		Ok(self.offboard(input_vtxos, address).await?)
293	}
294
295	/// Offboard the given VTXOs to a given [bitcoin::Address].
296	pub async fn offboard_vtxos<V: VtxoRef>(
297		&self,
298		vtxos: impl IntoIterator<Item = V>,
299		address: bitcoin::Address,
300	) -> anyhow::Result<Txid> {
301		let mut input_vtxos = vec![];
302		for v in vtxos {
303			let id = v.vtxo_id();
304			let vtxo = match self.db.get_wallet_vtxo(id).await? {
305				Some(vtxo) => vtxo,
306				_ => bail!("cannot find requested vtxo: {}", id),
307			};
308			input_vtxos.push(vtxo);
309		}
310
311		Ok(self.offboard(input_vtxos, address).await?)
312	}
313}