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;
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::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 input_ids = vtxos.iter().map(|v| v.as_ref().id()).collect::<Vec<_>>();
39		let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
40			offboard: Some(req.into()),
41			input_vtxo_ids: input_ids.iter()
42				.map(|id| id.to_bytes().to_vec())
43				.collect(),
44			attestation: vtxo_keys.iter()
45				.map(|k| OffboardRequestAttestation::new(req, &input_ids, k).serialize())
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 offboard_dest = ArkoorDestination {
119			total_amount: required_amount,
120			policy: VtxoPolicy::new_pubkey(offboard_pubkey.public_key()),
121		};
122		let arkoor = self.create_checkpointed_arkoor_with_vtxos(offboard_dest, vtxos.into_iter())
123			.await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
124
125		self.store_spendable_vtxos(&arkoor.change).await
126			.context("error storing change VTXOs from preparatory arkoor")?;
127		self.store_locked_vtxos(&arkoor.created, None).await
128			.context("error storing new VTXOs (locked) from preparatory arkoor")?;
129		self.mark_vtxos_as_spent(&arkoor.inputs).await
130			.context("error marking used input VTXOs as spent")?;
131
132		let mut movement = self.movements.new_guarded_movement_with_update(
133			Subsystem::OFFBOARD,
134			OffboardMovement::SendOnchain.to_string(),
135			OnDropStatus::Failed,
136			MovementUpdate::new()
137				.intended_balance(-amount.to_signed()?)
138				.effective_balance(-required_amount.to_signed()?)
139				.fee(fee)
140				.consumed_vtxos(&arkoor.inputs)
141				.produced_vtxos(&arkoor.change)
142				.metadata([(
143					"offboard_vtxos".into(),
144					serde_json::to_value(
145						arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
146					).expect("offboard_vtxos can serde"),
147				)])
148				.sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
149		).await?;
150		let state = VtxoState::Locked { movement_id: Some(movement.id()) };
151		self.set_vtxo_states(&arkoor.created, &state, &[]).await
152			.context("error setting movement id on locked VTXOs")?;
153
154		// now perform the offboard
155		let vtxos = arkoor.created;
156
157		let req = OffboardRequest {
158			script_pubkey: destination_spk.clone(),
159			net_amount: amount,
160			deduct_fees_from_gross_amount: false,
161			fee_rate: ark.offboard_feerate,
162		};
163		let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
164
165		let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
166			.context("error performing offboard")?;
167
168		movement.apply_update(MovementUpdate::new()
169			.metadata(OffboardMovement::metadata(&signed_offboard_tx))
170		).await.context("error updating movement")?;
171
172		if self.config.offboard_required_confirmations == 0 {
173			// No confirmation required — mark VTXOs as spent and succeed immediately
174			for vtxo in &vtxos {
175				self.db.update_vtxo_state_checked(
176					vtxo.id(),
177					VtxoState::Spent,
178					&[crate::vtxo::VtxoStateKind::Locked],
179				).await.context("error marking vtxo as spent")?;
180			}
181			movement.success().await
182				.context("error finishing movement")?;
183		} else {
184			// Store as pending offboard — don't mark success until confirmed on chain
185			let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
186			self.db.store_pending_offboard(&PendingOffboard {
187				movement_id: movement.id(),
188				offboard_txid: signed_offboard_tx.compute_txid(),
189				offboard_tx: signed_offboard_tx.clone(),
190				vtxo_ids,
191				destination: destination.to_string(),
192				created_at: chrono::Local::now(),
193			}).await.context("error storing pending offboard")?;
194
195			// Disarm the guard so it doesn't auto-fail the movement on drop
196			movement.stop();
197		}
198
199		Ok(signed_offboard_tx.compute_txid())
200	}
201
202	async fn offboard(
203		&self,
204		vtxos: Vec<WalletVtxo>,
205		destination: bitcoin::Address,
206	) -> anyhow::Result<Txid> {
207		let (mut srv, ark) = self.require_server().await?;
208		let tip = self.chain.tip().await?;
209
210		let destination_spk = destination.script_pubkey();
211		let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
212		let fee = ark.fees.offboard.calculate(
213			&destination_spk,
214			vtxos_amount,
215			ark.offboard_feerate,
216			vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
217		).context("error calculating offboard fee")?;
218		let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
219		let vtxo_keys = {
220			let mut keys = Vec::with_capacity(vtxos.len());
221			for v in &vtxos {
222				keys.push(self.get_vtxo_key(v).await?);
223			}
224			keys
225		};
226
227		let req = OffboardRequest {
228			script_pubkey: destination_spk.clone(),
229			net_amount,
230			deduct_fees_from_gross_amount: true,
231			fee_rate: ark.offboard_feerate,
232		};
233
234		let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
235			.context("error performing offboard")?;
236
237		// Lock VTXOs instead of marking them as spent
238		let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
239		let effective_amt = -SignedAmount::try_from(vtxos_amount)
240			.expect("can't have this many vtxo sats");
241		let destination_str = destination.to_string();
242		let movement_id = self.movements.new_movement_with_update(
243			Subsystem::OFFBOARD,
244			OffboardMovement::Offboard.to_string(),
245			MovementUpdate::new()
246				.intended_balance(effective_amt)
247				.effective_balance(effective_amt)
248				.fee(fee)
249				.consumed_vtxos(&vtxos)
250				.sent_to([MovementDestination::bitcoin(destination, net_amount)])
251				.metadata(OffboardMovement::metadata(&signed_offboard_tx)),
252		).await?;
253
254		self.lock_vtxos(&vtxos, Some(movement_id)).await?;
255
256		if self.config.offboard_required_confirmations == 0 {
257			// No confirmation required — mark VTXOs as spent and succeed immediately
258			for vtxo in &vtxos {
259				self.db.update_vtxo_state_checked(
260					vtxo.vtxo_id(),
261					VtxoState::Spent,
262					&[crate::vtxo::VtxoStateKind::Locked],
263				).await.context("error marking vtxo as spent")?;
264			}
265			self.movements.finish_movement(
266				movement_id,
267				MovementStatus::Successful,
268			).await.context("error finishing movement")?;
269		} else {
270			// Store as pending offboard — wait for on-chain confirmation
271			self.db.store_pending_offboard(&PendingOffboard {
272				movement_id,
273				offboard_txid: signed_offboard_tx.compute_txid(),
274				offboard_tx: signed_offboard_tx.clone(),
275				vtxo_ids,
276				destination: destination_str,
277				created_at: chrono::Local::now(),
278			}).await.context("error storing pending offboard")?
279		}
280
281		Ok(signed_offboard_tx.compute_txid())
282	}
283
284	/// Offboard all VTXOs to a given [bitcoin::Address].
285	pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
286		let input_vtxos = self.spendable_vtxos().await?;
287		Ok(self.offboard(input_vtxos, address).await?)
288	}
289
290	/// Offboard the given VTXOs to a given [bitcoin::Address].
291	pub async fn offboard_vtxos<V: VtxoRef>(
292		&self,
293		vtxos: impl IntoIterator<Item = V>,
294		address: bitcoin::Address,
295	) -> anyhow::Result<Txid> {
296		let mut input_vtxos = vec![];
297		for v in vtxos {
298			let id = v.vtxo_id();
299			let vtxo = match self.db.get_wallet_vtxo(id).await? {
300				Some(vtxo) => vtxo,
301				_ => bail!("cannot find requested vtxo: {}", id),
302			};
303			input_vtxos.push(vtxo);
304		}
305
306		Ok(self.offboard(input_vtxos, address).await?)
307	}
308}