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