use anyhow::Context;
use bitcoin::{Amount, SignedAmount, Transaction, Txid};
use bitcoin::hashes::Hash;
use bitcoin::hex::DisplayHex;
use bitcoin::secp256k1::Keypair;
use log::info;
use ark::{musig, ProtocolEncoding, Vtxo, VtxoPolicy};
use ark::arkoor::ArkoorDestination;
use ark::attestations::OffboardRequestAttestation;
use ark::fees::{validate_and_subtract_fee_min_dust, VtxoFeeInfo};
use ark::offboard::{OffboardForfeitContext, OffboardRequest};
use ark::vtxo::{Full, VtxoRef};
use bitcoin_ext::P2TR_DUST;
use server_rpc::{protos, ServerConnection, TryFromBytes};
use crate::movement::manager::OnDropStatus;
use crate::vtxo::VtxoState;
use crate::{Wallet, WalletVtxo};
use crate::movement::update::MovementUpdate;
use crate::movement::{MovementDestination, MovementStatus};
use crate::persist::models::PendingOffboard;
use crate::subsystem::{OffboardMovement, Subsystem};
impl Wallet {
async fn offboard_inner(
&self,
srv: &mut ServerConnection,
vtxos: &[impl AsRef<Vtxo<Full>>],
vtxo_keys: &[Keypair],
req: &OffboardRequest,
) -> anyhow::Result<Transaction> {
self.register_vtxo_transactions_with_server(&vtxos).await?;
let input_ids = vtxos.iter().map(|v| v.as_ref().id()).collect::<Vec<_>>();
let prep_resp = srv.client.prepare_offboard(protos::PrepareOffboardRequest {
offboard: Some(req.into()),
input_vtxo_ids: input_ids.iter()
.map(|id| id.to_bytes().to_vec())
.collect(),
attestation: vtxo_keys.iter()
.map(|k| OffboardRequestAttestation::new(req, &input_ids, k).serialize())
.collect(),
}).await.context("prepare offboard request failed")?.into_inner();
let unsigned_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
&prep_resp.offboard_tx,
).with_context(|| format!(
"received invalid unsigned offboard tx from server: {}", prep_resp.offboard_tx.as_hex(),
))?;
let offboard_txid = unsigned_offboard_tx.compute_txid();
info!("Received unsigned offboard tx {} from server", offboard_txid);
let forfeit_cosign_nonces = prep_resp.forfeit_cosign_nonces.into_iter().map(|n| {
Ok(musig::PublicNonce::from_bytes(&n)
.context("received invalid public cosign nonce from server")?)
}).collect::<anyhow::Result<Vec<_>>>()?;
let ctx = OffboardForfeitContext::new(&vtxos, &unsigned_offboard_tx);
ctx.validate_offboard_tx(&req).context("received invalid offboard tx from server")?;
let sigs = ctx.user_sign_forfeits(&vtxo_keys, &forfeit_cosign_nonces);
let finish_resp = srv.client.finish_offboard(protos::FinishOffboardRequest {
offboard_txid: offboard_txid.as_byte_array().to_vec(),
user_nonces: sigs.public_nonces.iter().map(|n| n.serialize().to_vec()).collect(),
partial_signatures: sigs.partial_signatures.iter()
.map(|s| s.serialize().to_vec())
.collect(),
}).await.context("error sending offboard forfeit signatures to server")?.into_inner();
let signed_offboard_tx = bitcoin::consensus::deserialize::<Transaction>(
&finish_resp.signed_offboard_tx,
).with_context(|| format!(
"received invalid offboard tx from server: {}", finish_resp.signed_offboard_tx.as_hex(),
))?;
if signed_offboard_tx.compute_txid() != offboard_txid {
bail!("Signed offboard tx received from server is different from \
unsigned tx we forfeited for: unsigned={}, signed={}",
prep_resp.offboard_tx.as_hex(), finish_resp.signed_offboard_tx.as_hex(),
);
}
self.chain.broadcast_tx(&signed_offboard_tx).await.with_context(|| format!(
"error broadcasting offboard tx {} (tx={})",
offboard_txid, finish_resp.signed_offboard_tx.as_hex(),
))?;
Ok(signed_offboard_tx)
}
pub async fn send_onchain(
&self,
destination: bitcoin::Address,
amount: Amount,
) -> anyhow::Result<Txid> {
if amount < P2TR_DUST {
bail!("it doesn't make sense to send dust");
}
let (mut srv, ark) = self.require_server().await?;
let destination_spk = destination.script_pubkey();
let (vtxos, fee) = self.select_vtxos_to_cover_with_fee(amount, |a, v| {
ark.fees.offboard.calculate(&destination_spk, a, ark.offboard_feerate, v)
.ok_or_else(|| anyhow!("failed to calculate offboard fee for {}", a))
}).await?;
let required_amount = amount + fee;
info!("We can only offboard whole VTXOs, so we will make an arkoor tx first...");
let offboard_pubkey = self.derive_store_next_keypair().await
.context("failed to create new keypair")?.0;
let offboard_dest = ArkoorDestination {
total_amount: required_amount,
policy: VtxoPolicy::new_pubkey(offboard_pubkey.public_key()),
};
let arkoor = self.create_checkpointed_arkoor_with_vtxos(offboard_dest, vtxos.into_iter())
.await.context("error trying to prepare offboard VTXOs with an arkoor tx")?;
self.store_spendable_vtxos(&arkoor.change).await
.context("error storing change VTXOs from preparatory arkoor")?;
self.store_locked_vtxos(&arkoor.created, None).await
.context("error storing new VTXOs (locked) from preparatory arkoor")?;
self.mark_vtxos_as_spent(&arkoor.inputs).await
.context("error marking used input VTXOs as spent")?;
let mut movement = self.movements.new_guarded_movement_with_update(
Subsystem::OFFBOARD,
OffboardMovement::SendOnchain.to_string(),
OnDropStatus::Failed,
MovementUpdate::new()
.intended_balance(-amount.to_signed()?)
.effective_balance(-required_amount.to_signed()?)
.fee(fee)
.consumed_vtxos(&arkoor.inputs)
.produced_vtxos(&arkoor.change)
.metadata([(
"offboard_vtxos".into(),
serde_json::to_value(
arkoor.created.iter().map(|v| v.id()).collect::<Vec<_>>(),
).expect("offboard_vtxos can serde"),
)])
.sent_to([MovementDestination::bitcoin(destination.clone(), amount)])
).await?;
let state = VtxoState::Locked { movement_id: Some(movement.id()) };
self.set_vtxo_states(&arkoor.created, &state, &[]).await
.context("error setting movement id on locked VTXOs")?;
let vtxos = arkoor.created;
let req = OffboardRequest {
script_pubkey: destination_spk.clone(),
net_amount: amount,
deduct_fees_from_gross_amount: false,
fee_rate: ark.offboard_feerate,
};
let vtxo_keys = vec![offboard_pubkey; vtxos.len()];
let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
.context("error performing offboard")?;
movement.apply_update(MovementUpdate::new()
.metadata(OffboardMovement::metadata(&signed_offboard_tx))
).await.context("error updating movement")?;
if self.config.offboard_required_confirmations == 0 {
for vtxo in &vtxos {
self.db.update_vtxo_state_checked(
vtxo.id(),
VtxoState::Spent,
&[crate::vtxo::VtxoStateKind::Locked],
).await.context("error marking vtxo as spent")?;
}
movement.success().await
.context("error finishing movement")?;
} else {
let vtxo_ids = vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
self.db.store_pending_offboard(&PendingOffboard {
movement_id: movement.id(),
offboard_txid: signed_offboard_tx.compute_txid(),
offboard_tx: signed_offboard_tx.clone(),
vtxo_ids,
destination: destination.to_string(),
created_at: chrono::Local::now(),
}).await.context("error storing pending offboard")?;
movement.stop();
}
Ok(signed_offboard_tx.compute_txid())
}
async fn offboard(
&self,
vtxos: Vec<WalletVtxo>,
destination: bitcoin::Address,
) -> anyhow::Result<Txid> {
let (mut srv, ark) = self.require_server().await?;
let tip = self.chain.tip().await?;
let destination_spk = destination.script_pubkey();
let vtxos_amount = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
let fee = ark.fees.offboard.calculate(
&destination_spk,
vtxos_amount,
ark.offboard_feerate,
vtxos.iter().map(|v| VtxoFeeInfo::from_vtxo_and_tip(v, tip)),
).context("error calculating offboard fee")?;
let net_amount = validate_and_subtract_fee_min_dust(vtxos_amount, fee)?;
let vtxo_keys = {
let mut keys = Vec::with_capacity(vtxos.len());
for v in &vtxos {
keys.push(self.get_vtxo_key(v).await?);
}
keys
};
let req = OffboardRequest {
script_pubkey: destination_spk.clone(),
net_amount,
deduct_fees_from_gross_amount: true,
fee_rate: ark.offboard_feerate,
};
let signed_offboard_tx = self.offboard_inner(&mut srv, &vtxos, &vtxo_keys, &req).await
.context("error performing offboard")?;
let vtxo_ids = vtxos.iter().map(|v| v.vtxo_id()).collect::<Vec<_>>();
let effective_amt = -SignedAmount::try_from(vtxos_amount)
.expect("can't have this many vtxo sats");
let destination_str = destination.to_string();
let movement_id = self.movements.new_movement_with_update(
Subsystem::OFFBOARD,
OffboardMovement::Offboard.to_string(),
MovementUpdate::new()
.intended_balance(effective_amt)
.effective_balance(effective_amt)
.fee(fee)
.consumed_vtxos(&vtxos)
.sent_to([MovementDestination::bitcoin(destination, net_amount)])
.metadata(OffboardMovement::metadata(&signed_offboard_tx)),
).await?;
self.lock_vtxos(&vtxos, Some(movement_id)).await?;
if self.config.offboard_required_confirmations == 0 {
for vtxo in &vtxos {
self.db.update_vtxo_state_checked(
vtxo.vtxo_id(),
VtxoState::Spent,
&[crate::vtxo::VtxoStateKind::Locked],
).await.context("error marking vtxo as spent")?;
}
self.movements.finish_movement(
movement_id,
MovementStatus::Successful,
).await.context("error finishing movement")?;
} else {
self.db.store_pending_offboard(&PendingOffboard {
movement_id,
offboard_txid: signed_offboard_tx.compute_txid(),
offboard_tx: signed_offboard_tx.clone(),
vtxo_ids,
destination: destination_str,
created_at: chrono::Local::now(),
}).await.context("error storing pending offboard")?
}
Ok(signed_offboard_tx.compute_txid())
}
pub async fn offboard_all(&self, address: bitcoin::Address) -> anyhow::Result<Txid> {
let input_vtxos = self.spendable_vtxos().await?;
Ok(self.offboard(input_vtxos, address).await?)
}
pub async fn offboard_vtxos<V: VtxoRef>(
&self,
vtxos: impl IntoIterator<Item = V>,
address: bitcoin::Address,
) -> anyhow::Result<Txid> {
let mut input_vtxos = vec![];
for v in vtxos {
let id = v.vtxo_id();
let vtxo = match self.db.get_wallet_vtxo(id).await? {
Some(vtxo) => vtxo,
_ => bail!("cannot find requested vtxo: {}", id),
};
input_vtxos.push(vtxo);
}
Ok(self.offboard(input_vtxos, address).await?)
}
}