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 self.register_vtxo_transactions_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 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 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 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 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 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 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 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 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 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 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 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}