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 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 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 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 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 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 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 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 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 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 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 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 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 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}