bark/
offboard.rs

1
2use std::ops::Mul;
3
4use anyhow::Context;
5use bitcoin::hashes::Hash;
6use bitcoin::hex::DisplayHex;
7use bitcoin::{Amount, SignedAmount, Transaction, Txid};
8use log::info;
9
10use ark::musig;
11use ark::challenges::OffboardRequestChallenge;
12use ark::offboard::{OffboardForfeitContext, OffboardRequest};
13use ark::vtxo::VtxoRef;
14use server_rpc::{protos, TryFromBytes};
15
16use crate::{Wallet, WalletVtxo};
17use crate::movement::update::MovementUpdate;
18use crate::movement::{MovementDestination, MovementStatus};
19use crate::server::ArkInfoExt;
20use crate::subsystem::{OffboardMovement, Subsystem};
21
22
23impl Wallet {
24	async fn offboard(
25		&self,
26		vtxos: Vec<WalletVtxo>,
27		destination: bitcoin::Address,
28	) -> anyhow::Result<Txid> {
29		let mut srv = self.require_server()?;
30		let ark = srv.ark_info().await?;
31
32		let destination_spk = destination.script_pubkey();
33		let fee = ark.calculate_offboard_fee(&destination_spk)?;
34		let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
35		let req_amount = vtxos_amount.checked_sub(fee)
36			.context("fee is higher than selected VTXOs")?;
37
38		let req = OffboardRequest {
39			script_pubkey: destination_spk.clone(),
40			amount: req_amount,
41		};
42		let challenge = OffboardRequestChallenge::new(&req, vtxos.iter().map(|v| v.id()));
43
44		let vtxo_keys = {
45			let mut keys = Vec::with_capacity(vtxos.len());
46			for v in &vtxos {
47				keys.push(self.get_vtxo_key(v).await?);
48			}
49			keys
50		};
51		let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
52			offboard: Some((&req).into()),
53			input_vtxo_ids: vtxos.iter().map(|v| v.id().to_bytes().to_vec()).collect(),
54			input_vtxo_ownership_proofs: vtxo_keys.iter()
55				.map(|k| challenge.sign_with(k).serialize().to_vec())
56				.collect(),
57		}).await.context("prepare offboard request failed")?.into_inner();
58		let unsigned_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
59			&prep_resp.offboard_tx,
60		).with_context(|| format!(
61			"received invalid unsigned offboard tx from server: {}", prep_resp.offboard_tx.as_hex(),
62		))?;
63		let offboard_txid = unsigned_offboard_tx.compute_txid();
64		info!("Received unsigned offboard tx {} from server", offboard_txid);
65		let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
66			Ok(musig::PublicNonce::from_bytes(&n)
67				.context("received invalid public cosign nonce from server")?)
68		}).collect::<anyhow::Result<Vec<_>>>()?;
69
70		let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_offboard_tx);
71		ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
72
73		let sigs = ctx.user_sign_forfeits(&vtxo_keys, &forfeit_cosign_nonces);
74
75		let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
76			offboard_txid: offboard_txid.as_byte_array().to_vec(),
77			user_nonces: sigs.public_nonces.iter().map(|n| n.serialize().to_vec()).collect(),
78			partial_signatures: sigs.partial_signatures.iter()
79				.map(|s| s.serialize().to_vec())
80				.collect(),
81		}).await.context("error sending offboard forfeit signatures to server")?.into_inner();
82
83		let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
84			&finish_resp.signed_offboard_tx,
85		).with_context(|| format!(
86			"received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
87		))?;
88		if signed_offboard_tx.compute_txid() != offboard_txid {
89			bail!("Signed offboard tx received from server is different from \
90				unsigned tx we forfeited for: unsigned={}, signed={}",
91				prep_resp.offboard_tx.as_hex(), finish_resp.signed_offboard_tx.as_hex(),
92			);
93		}
94		// we don't accept the tx if our mempool doesn't accept it, it might be a double spend
95		self.chain.broadcast_tx(&signed_offboard_tx).await.with_context(|| format!(
96			"error broadcasting offboard tx {} (tx={})",
97			offboard_txid, finish_resp.signed_offboard_tx.as_hex(),
98		))?;
99
100		self.mark_vtxos_as_spent(&vtxos).await?;
101		let effective_amt = SignedAmount::try_from(vtxos_amount)
102			.expect("can't have this many vtxo sats")
103			.mul(-1);
104		self.movements.new_finished_movement(
105			Subsystem::OFFBOARD,
106			OffboardMovement::Offboard.to_string(),
107			MovementStatus::Successful,
108			MovementUpdate::new()
109				.intended_balance(effective_amt)
110				.effective_balance(effective_amt)
111				.fee(fee)
112				.consumed_vtxos(&vtxos)
113				.sent_to([MovementDestination::bitcoin(destination, req_amount)])
114				.metadata(OffboardMovement::metadata(&signed_offboard_tx)),
115		).await?;
116
117		Ok(offboard_txid)
118	}
119
120	/// Offboard all VTXOs to a given [bitcoin::Address].
121	pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
122		let input_vtxos = self.spendable_vtxos().await?;
123		Ok(self.offboard(input_vtxos, address).await?)
124	}
125
126	/// Offboard the given VTXOs to a given [bitcoin::Address].
127	pub async fn offboard_vtxos<V: VtxoRef>(
128		&self,
129		vtxos: impl IntoIterator<Item = V>,
130		address: bitcoin::Address,
131	) -> anyhow::Result<Txid> {
132		let mut input_vtxos = vec![];
133		for v in vtxos {
134			let id = v.vtxo_id();
135			let vtxo = match self.db.get_wallet_vtxo(id).await? {
136				Some(vtxo) => vtxo,
137				_ => bail!("cannot find requested vtxo: {}", id),
138			};
139			input_vtxos.push(vtxo);
140		}
141
142		Ok(self.offboard(input_vtxos, address).await?)
143	}
144}