diff --git a/src/messages.rs b/src/messages.rs index aed16ad..54f4e71 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -17,6 +17,7 @@ use nom::{ IResult, }; +use crate::proto::common::Hex; use crate::types::{ SidechainDeclaration, SidechainDescription, SidechainNumber, SidechainProposal, }; @@ -262,6 +263,20 @@ pub fn create_m5_deposit_output( } } +pub fn create_op_return_output(message: Hex) -> TxOut { + let mut script_bytes = vec![OP_RETURN.to_u8()]; + let hex_value = message.hex.as_ref().expect("hex value must be present"); + script_bytes.push(hex_value.len() as u8); + script_bytes.extend(hex_value.as_bytes()); + + let script_pubkey = ScriptBuf::from_bytes(script_bytes); + + TxOut { + script_pubkey, + value: Amount::ZERO, + } +} + fn parse_m1_propose_sidechain(input: &[u8]) -> IResult<&[u8], CoinbaseMessage> { let (input, sidechain_number) = take(1usize)(input)?; let sidechain_number = sidechain_number[0]; diff --git a/src/server.rs b/src/server.rs index bf04f4a..ccb585d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc, vec}; +use std::{collections::HashMap, str::FromStr, sync::Arc, vec}; use bdk_wallet::chain::{ChainPosition, ConfirmationBlockTime}; use bitcoin::{ @@ -29,9 +29,13 @@ use crate::{ }, mainchain::{ create_sidechain_proposal_response, get_bmm_h_star_commitment_response, - get_ctip_response::Ctip, get_sidechain_proposals_response::SidechainProposal, - get_sidechains_response::SidechainInfo, server::ValidatorService, - wallet_service_server::WalletService, wallet_transaction::Confirmation, + get_ctip_response::Ctip, + get_sidechain_proposals_response::SidechainProposal, + get_sidechains_response::SidechainInfo, + send_transaction_request::{self}, + server::ValidatorService, + wallet_service_server::WalletService, + wallet_transaction::Confirmation, BroadcastWithdrawalBundleRequest, BroadcastWithdrawalBundleResponse, CreateBmmCriticalDataTransactionRequest, CreateBmmCriticalDataTransactionResponse, CreateDepositTransactionRequest, CreateDepositTransactionResponse, @@ -44,8 +48,8 @@ use crate::{ GetCoinbasePsbtResponse, GetCtipRequest, GetCtipResponse, GetSidechainProposalsRequest, GetSidechainProposalsResponse, GetSidechainsRequest, GetSidechainsResponse, GetTwoWayPegDataRequest, GetTwoWayPegDataResponse, ListTransactionsRequest, - ListTransactionsResponse, Network, SubscribeEventsRequest, SubscribeEventsResponse, - WalletTransaction, + ListTransactionsResponse, Network, SendTransactionRequest, SendTransactionResponse, + SubscribeEventsRequest, SubscribeEventsResponse, WalletTransaction, }, }, types::{Event, SidechainNumber}, @@ -855,6 +859,7 @@ impl WalletService for Arc { value_sats, fee_sats, } = request.into_inner(); + let sidechain_number = sidechain_id .ok_or_else(|| missing_field::("sidechain_id")) .map(SidechainNumber::try_from)? @@ -973,6 +978,68 @@ impl WalletService for Arc { }; Ok(tonic::Response::new(response)) } + + async fn send_transaction( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let SendTransactionRequest { + destinations, + fee_rate, + op_return_message, + } = request.into_inner(); + + // Extract fee rate per vbyte + let fee_rate_per_vbyte = match fee_rate { + Some(fee_rate) => match fee_rate.fee { + Some(send_transaction_request::fee_rate::Fee::SatPerVbyte(rate)) => { + Some(Amount::from_sat(rate)) + } + _ => None, + }, + None => None, + }; + + // Or hard-coded fee + let fee = match fee_rate { + Some(fee_rate) => match fee_rate.fee { + Some(send_transaction_request::fee_rate::Fee::Sats(sats)) => { + Some(Amount::from_sat(sats)) + } + _ => None, + }, + None => None, + }; + + // Parse and validate all destination addresses, but assume network valid + let destinations_validated = destinations + .iter() + .map(|(address, amount)| { + bdk_wallet::bitcoin::Address::from_str(address) + .map_err(|e| { + tonic::Status::invalid_argument(format!( + "could not parse bitcoin address: {}", + e + )) + }) + .map(|addr| (addr.assume_checked(), *amount)) + }) + .collect::, tonic::Status>>()?; + + let txid = self + .send_wallet_transaction( + destinations_validated, + fee_rate_per_vbyte, + fee, + op_return_message, + ) + .await + .map_err(|err| err.into_status())?; + + let txid = ReverseHex::encode(&txid); + let response = SendTransactionResponse { txid: Some(txid) }; + Ok(tonic::Response::new(response)) + } } #[derive(Debug, Error)] diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 17ad268..04610a1 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -40,7 +40,7 @@ use bitcoin::{ OP_0, }, transaction::Version as TxVersion, - Amount, Block, Network, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, + Amount, Block, FeeRate, Network, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, }; use miette::{miette, IntoDiagnostic, Result}; use parking_lot::{Mutex, RwLock}; @@ -50,7 +50,7 @@ use crate::{ cli::WalletConfig, convert, messages::{self, CoinbaseBuilder, M8_BMM_REQUEST_TAG}, - proto::mainchain::WalletTransaction, + proto::common::Hex, types::{BDKWalletTransaction, Ctip, SidechainAck, SidechainNumber, SidechainProposal}, validator::Validator, }; @@ -706,6 +706,17 @@ impl Wallet { } } + fn create_op_return_output(message: Hex) -> bdk_wallet::bitcoin::TxOut { + let op_return_txout = messages::create_op_return_output(message); + + bdk_wallet::bitcoin::TxOut { + script_pubkey: bdk_wallet::bitcoin::ScriptBuf::from_bytes( + op_return_txout.script_pubkey.to_bytes(), + ), + value: op_return_txout.value, + } + } + async fn fetch_transaction(&self, txid: Txid) -> Result { let block_hash = None; @@ -1051,6 +1062,81 @@ impl Wallet { Ok(txs) } + async fn create_send_psbt( + &self, + destinations: HashMap, + fee_rate_per_vbyte: Option, + fee: Option, + op_return_output: Option, + ) -> Result { + let psbt = { + let mut wallet = self.bitcoin_wallet.lock(); + let mut builder = wallet.borrow_mut().build_tx(); + + if let Some(op_return_output) = op_return_output { + builder.add_recipient(op_return_output.script_pubkey, op_return_output.value); + } + + // Add outputs for each destination address + for (address, value) in destinations { + builder.add_recipient(address.script_pubkey(), Amount::from_sat(value)); + } + + if let Some(fee) = fee { + builder.fee_absolute(fee); + } + + if let Some(rate) = fee_rate_per_vbyte { + let fee_rate = bitcoin::FeeRate::from_sat_per_vb(rate.to_sat() as u64) + .expect("could not create fee rate"); + builder.fee_rate(fee_rate); + } + + builder.finish().into_diagnostic()? + }; + + Ok(psbt) + } + + /// Creates a transaction, sends it, and returns the TXID. + pub async fn send_wallet_transaction( + &self, + destinations: HashMap, + fee_rate_sats_per_vbyte: Option, + fee_rate_sats: Option, + op_return_message: Option, + ) -> Result { + let op_return_output = + op_return_message.map(|message| Self::create_op_return_output(message)); + + let psbt = self + .create_send_psbt( + destinations, + fee_rate_sats_per_vbyte, + fee_rate_sats, + op_return_output, + ) + .await?; + + tracing::debug!("Created send PSBT: {psbt}",); + + let tx = self.sign_transaction(psbt)?; + let txid = tx.compute_txid(); + + tracing::info!("Signed send transaction: `{txid}`",); + + tracing::debug!("Serialized send transaction: {}", { + let tx_bytes = bdk_wallet::bitcoin::consensus::serialize(&tx); + hex::encode(tx_bytes) + }); + + self.broadcast_transaction(tx).await?; + + tracing::info!("Broadcasted send transaction: `{txid}`",); + + Ok(convert::bdk_txid_to_bitcoin_txid(txid)) + } + pub fn sync(&self) -> Result<()> { let start = SystemTime::now(); tracing::trace!("starting wallet sync");