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, PublicKey, Script, Transaction, Txid}; use bdk::blockchain::{ElectrumBlockchain, NoopProgress}; use bdk::wallet::AddressIndex; use bdk::{electrum_client, FeeRate, KeychainKind, SignOptions}; use maia::{PartyParams, WalletExt}; use rocket::serde::json::Value; use std::path::Path; use std::sync::Arc; use tokio::sync::Mutex; use xtra_productivity::xtra_productivity; const DUST_AMOUNT: u64 = 546; #[derive(Clone)] pub struct Actor { wallet: Arc>>, } #[derive(thiserror::Error, Debug, Clone, Copy)] #[error("The transaction is already in the blockchain")] pub struct TransactionAlreadyInBlockchain; impl Actor { pub async 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), )?; let wallet = Arc::new(Mutex::new(wallet)); Ok(Self { wallet }) } /// 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 async fn max_giveable( &self, locking_script_size: usize, fee_rate: FeeRate, ) -> Result { let wallet = self.wallet.lock().await; let balance = 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 = 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.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 async fn handle_sync(&self, _msg: Sync) -> Result { let wallet = self.wallet.lock().await; wallet .sync(NoopProgress, None) .context("Failed to sync wallet")?; let balance = wallet.get_balance()?; let address = 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 async fn handle_sign(&self, msg: Sign) -> Result { let mut psbt = msg.psbt; let wallet = self.wallet.lock().await; wallet .sign( &mut psbt, SignOptions { trust_witness_utxo: true, ..Default::default() }, ) .context("could not sign transaction")?; Ok(psbt) } pub async fn build_party_params( &self, BuildPartyParams { amount, identity_pk, }: BuildPartyParams, ) -> Result { let wallet = self.wallet.lock().await; wallet.build_party_params(amount, identity_pk) } pub async fn handle_try_broadcast_transaction( &self, msg: TryBroadcastTransaction, ) -> Result { let tx = msg.tx; let wallet = self.wallet.lock().await; let txid = tx.txid(); let result = wallet.broadcast(tx.clone()); 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 async fn handle_withdraw(&self, msg: Withdraw) -> Result { 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) .await .context("Unable to drain wallet")? }; tracing::info!(%amount, %address, "Amount to be sent to address"); let wallet = self.wallet.lock().await; let mut tx_builder = 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()?; wallet.sign(&mut psbt, SignOptions::default())?; let txid = wallet.broadcast(psbt.extract_tx())?; 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, } } } #[cfg(test)] mod tests { use super::*; #[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); } }