use crate::model::{Timestamp, WalletInfo}; use anyhow::{bail, Context, Result}; use bdk::bitcoin::consensus::encode::serialize_hex; use bdk::bitcoin::util::bip32::ExtendedPrivKey; use bdk::bitcoin::util::psbt::PartiallySignedTransaction; use bdk::bitcoin::{Address, Amount, OutPoint, PublicKey, Script, Transaction, Txid}; use bdk::blockchain::{ElectrumBlockchain, NoopProgress}; use bdk::database::BatchDatabase; use bdk::wallet::tx_builder::TxOrdering; use bdk::wallet::AddressIndex; use bdk::{electrum_client, FeeRate, KeychainKind, SignOptions}; use maia::{PartyParams, TxBuilderExt}; use rocket::serde::json::Value; use std::collections::HashSet; use std::path::Path; use xtra_productivity::xtra_productivity; const DUST_AMOUNT: u64 = 546; pub struct Actor { wallet: bdk::Wallet, used_utxos: HashSet, } #[derive(thiserror::Error, Debug, Clone, Copy)] #[error("The transaction is already in the blockchain")] pub struct TransactionAlreadyInBlockchain; impl Actor { pub fn new( electrum_rpc_url: &str, wallet_dir: &Path, ext_priv_key: ExtendedPrivKey, ) -> Result { let client = bdk::electrum_client::Client::new(electrum_rpc_url) .context("Failed to initialize Electrum RPC client")?; let db = bdk::database::SqliteDatabase::new(wallet_dir.display().to_string()); let wallet = bdk::Wallet::new( bdk::template::Bip84(ext_priv_key, KeychainKind::External), Some(bdk::template::Bip84(ext_priv_key, KeychainKind::Internal)), ext_priv_key.network, db, ElectrumBlockchain::from(client), )?; Ok(Self { wallet, used_utxos: HashSet::default(), }) } /// Calculates the maximum "giveable" amount of this wallet. /// /// We define this as the maximum amount we can pay to a single output, /// given a fee rate. pub fn max_giveable(&self, locking_script_size: usize, fee_rate: FeeRate) -> Result { let balance = self.wallet.get_balance()?; // TODO: Do we have to deal with the min_relay_fee here as well, i.e. if balance below // min_relay_fee we should return Amount::ZERO? if balance < DUST_AMOUNT { return Ok(Amount::ZERO); } let mut tx_builder = self.wallet.build_tx(); let dummy_script = Script::from(vec![0u8; locking_script_size]); tx_builder.drain_to(dummy_script); tx_builder.fee_rate(fee_rate); tx_builder.unspendable(self.used_utxos.iter().copied().collect()); tx_builder.drain_wallet(); let response = tx_builder.finish(); match response { Ok((_, details)) => { let max_giveable = details.sent - details .fee .expect("fees are always present with Electrum backend"); Ok(Amount::from_sat(max_giveable)) } Err(bdk::Error::InsufficientFunds { .. }) => Ok(Amount::ZERO), Err(e) => bail!("Failed to build transaction. {:#}", e), } } } #[xtra_productivity] impl Actor { pub fn handle_sync(&mut self, _msg: Sync) -> Result { self.wallet .sync(NoopProgress, None) .context("Failed to sync wallet")?; let balance = self.wallet.get_balance()?; let address = self.wallet.get_address(AddressIndex::LastUnused)?.address; let wallet_info = WalletInfo { balance: Amount::from_sat(balance), address, last_updated_at: Timestamp::now(), }; Ok(wallet_info) } pub fn handle_sign(&mut self, msg: Sign) -> Result { let mut psbt = msg.psbt; self.wallet .sign( &mut psbt, SignOptions { trust_witness_utxo: true, ..Default::default() }, ) .context("could not sign transaction")?; Ok(psbt) } pub fn build_party_params( &mut self, BuildPartyParams { amount, identity_pk, }: BuildPartyParams, ) -> Result { let psbt = self.wallet.build_lock_tx(amount, &mut self.used_utxos)?; Ok(PartyParams { lock_psbt: psbt, identity_pk, lock_amount: amount, address: self.wallet.get_address(AddressIndex::New)?.address, }) } pub fn handle_try_broadcast_transaction( &mut self, msg: TryBroadcastTransaction, ) -> Result { let tx = msg.tx; let txid = tx.txid(); let result = self.wallet.broadcast(&tx); if let Err(&bdk::Error::Electrum(electrum_client::Error::Protocol(ref value))) = result.as_ref() { let error_code = parse_rpc_protocol_error_code(value).with_context(|| { format!("Failed to parse electrum error response '{:?}'", value) })?; if error_code == i64::from(RpcErrorCode::RpcVerifyAlreadyInChain) { tracing::trace!( %txid, "Attempted to broadcast transaction that was already on-chain", ); return Ok(txid); } } let txid = result.with_context(|| { format!( "Broadcasting transaction failed. Txid: {}. Raw transaction: {}", txid, serialize_hex(&tx) ) })?; Ok(txid) } pub fn handle_withdraw(&mut self, msg: Withdraw) -> Result { self.wallet .sync(NoopProgress, None) .context("Failed to sync wallet")?; if msg.address.network != self.wallet.network() { bail!( "Address has invalid network. It was {} but the wallet is connected to {}", msg.address.network, self.wallet.network() ) } let fee_rate = msg.fee.unwrap_or_else(FeeRate::default_min_relay_fee); let address = msg.address; let amount = if let Some(amount) = msg.amount { amount } else { self.max_giveable(address.script_pubkey().len(), fee_rate) .context("Unable to drain wallet")? }; tracing::info!(%amount, %address, "Amount to be sent to address"); let mut tx_builder = self.wallet.build_tx(); tx_builder .add_recipient(address.script_pubkey(), amount.as_sat()) .fee_rate(fee_rate) // Turn on RBF signaling .enable_rbf(); let (mut psbt, _) = tx_builder.finish()?; self.wallet.sign(&mut psbt, SignOptions::default())?; let txid = self.wallet.broadcast(&psbt.extract_tx())?; tracing::info!(%txid, "Withdraw successful"); Ok(txid) } } impl xtra::Actor for Actor {} pub struct BuildPartyParams { pub amount: Amount, pub identity_pk: PublicKey, } pub struct Sync; pub struct Sign { pub psbt: PartiallySignedTransaction, } pub struct TryBroadcastTransaction { pub tx: Transaction, } pub struct Withdraw { pub amount: Option, pub fee: Option, pub address: Address, } fn parse_rpc_protocol_error_code(error_value: &Value) -> Result { let json = error_value .as_str() .context("Not a string")? .split_terminator("RPC error: ") .nth(1) .context("Unknown error code format")?; let error = serde_json::from_str::(json).context("Error has unexpected format")?; Ok(error.code) } #[derive(serde::Deserialize)] struct RpcError { code: i64, } /// Bitcoin error codes: pub enum RpcErrorCode { /// Transaction or block was rejected by network rules. Error code -27. RpcVerifyAlreadyInChain, } impl From for i64 { fn from(code: RpcErrorCode) -> Self { match code { RpcErrorCode::RpcVerifyAlreadyInChain => -27, } } } /// Module private trait to faciliate testing. /// /// Implementing this generically on `bdk::Wallet` allows us to call it on a dummy wallet in the /// test. trait BuildLockTx { fn build_lock_tx( &mut self, amount: Amount, used_utxos: &mut HashSet, ) -> Result; } impl BuildLockTx for bdk::Wallet where D: BatchDatabase, { fn build_lock_tx( &mut self, amount: Amount, used_utxos: &mut HashSet, ) -> Result { let mut builder = self.build_tx(); builder .ordering(TxOrdering::Bip69Lexicographic) // TODO: I think this is pointless but we did this in maia. .fee_rate(FeeRate::from_sat_per_vb(1.0)) .unspendable(used_utxos.iter().copied().collect()) .add_2of2_multisig_recipient(amount); let (psbt, _) = builder.finish()?; let used_inputs = psbt .global .unsigned_tx .input .iter() .map(|input| input.previous_output); used_utxos.extend(used_inputs); Ok(psbt) } } #[cfg(test)] mod tests { use super::*; use crate::bdk_ext::new_test_wallet; use rand::thread_rng; use std::collections::HashSet; #[test] fn parse_error_response() { let response = serde_json::Value::String(r#"sendrawtransaction RPC error: {"code":-27,"message":"Transaction already in block chain"}"#.to_owned()); let code = parse_rpc_protocol_error_code(&response).unwrap(); assert_eq!(code, -27); } #[test] fn creating_two_lock_transactions_uses_different_utxos() { let mut wallet = new_test_wallet(&mut thread_rng(), Amount::from_sat(1000), 10).unwrap(); let mut used_utxos = HashSet::new(); let lock_tx_1 = wallet .build_lock_tx(Amount::from_sat(2500), &mut used_utxos) .unwrap(); let lock_tx_2 = wallet .build_lock_tx(Amount::from_sat(2500), &mut used_utxos) .unwrap(); let mut utxos_in_transaction = HashSet::new(); utxos_in_transaction.extend( lock_tx_1 .global .unsigned_tx .input .iter() .map(|i| i.previous_output), ); utxos_in_transaction.extend( lock_tx_2 .global .unsigned_tx .input .iter() .map(|i| i.previous_output), ); // 2 TX a 2500 sats with UTXOs worth 1000s = 6 inputs // If there are 6 UTXOs in the HashSet, we know that they are all different (HashSets don't // allow duplicates!) let expected_num_utxos = 6; assert_eq!(utxos_in_transaction.len(), expected_num_utxos); assert_eq!(utxos_in_transaction, used_utxos); } }