2021-01-05 03:08:36 +00:00
|
|
|
use crate::{
|
|
|
|
bitcoin::{
|
2021-02-25 23:14:43 +00:00
|
|
|
timelocks::BlockHeight, Address, Amount, BroadcastSignedTransaction, GetBlockHeight,
|
|
|
|
GetRawTransaction, SignTxLock, Transaction, TransactionBlockHeight, TxLock,
|
|
|
|
WaitForTransactionFinality, WatchForRawTransaction,
|
2021-01-05 03:08:36 +00:00
|
|
|
},
|
2021-01-29 06:27:50 +00:00
|
|
|
execution_params::ExecutionParams,
|
2021-01-05 03:08:36 +00:00
|
|
|
};
|
2021-01-21 00:20:57 +00:00
|
|
|
use ::bitcoin::{util::psbt::PartiallySignedTransaction, Txid};
|
2021-02-17 00:09:09 +00:00
|
|
|
use anyhow::{anyhow, bail, Context, Result};
|
2021-01-21 00:20:57 +00:00
|
|
|
use async_trait::async_trait;
|
2021-02-19 01:33:59 +00:00
|
|
|
use backoff::{backoff::Constant as ConstantBackoff, future::retry};
|
2021-02-01 23:39:34 +00:00
|
|
|
use bdk::{
|
|
|
|
blockchain::{noop_progress, Blockchain, ElectrumBlockchain},
|
2021-02-15 05:04:51 +00:00
|
|
|
electrum_client::{self, Client, ElectrumApi},
|
2021-02-09 06:23:13 +00:00
|
|
|
miniscript::bitcoin::PrivateKey,
|
2021-02-01 23:39:34 +00:00
|
|
|
FeeRate,
|
|
|
|
};
|
|
|
|
use reqwest::{Method, Url};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use std::{path::Path, sync::Arc, time::Duration};
|
|
|
|
use tokio::{sync::Mutex, time::interval};
|
|
|
|
|
|
|
|
const SLED_TREE_NAME: &str = "default_tree";
|
2021-01-05 03:08:36 +00:00
|
|
|
|
2021-02-17 00:09:09 +00:00
|
|
|
#[derive(Debug, thiserror::Error)]
|
2021-02-15 05:13:29 +00:00
|
|
|
enum Error {
|
2021-02-17 00:09:09 +00:00
|
|
|
#[error("Sending the request failed")]
|
2021-02-15 05:13:29 +00:00
|
|
|
Io(reqwest::Error),
|
2021-02-17 00:09:09 +00:00
|
|
|
#[error("Conversion to Integer failed")]
|
2021-02-15 05:13:29 +00:00
|
|
|
Parse(std::num::ParseIntError),
|
2021-02-17 00:09:09 +00:00
|
|
|
#[error("The transaction is not minded yet")]
|
2021-02-15 05:13:29 +00:00
|
|
|
NotYetMined,
|
2021-02-17 00:09:09 +00:00
|
|
|
#[error("Deserialization failed")]
|
|
|
|
JsonDeserialization(reqwest::Error),
|
2021-02-17 00:32:41 +00:00
|
|
|
#[error("Electrum client error")]
|
|
|
|
ElectrumClient(electrum_client::Error),
|
2021-02-15 05:13:29 +00:00
|
|
|
}
|
|
|
|
|
2021-01-05 03:08:36 +00:00
|
|
|
pub struct Wallet {
|
2021-02-25 02:22:03 +00:00
|
|
|
inner: Arc<Mutex<bdk::Wallet<ElectrumBlockchain, bdk::sled::Tree>>>,
|
|
|
|
http_url: Url,
|
|
|
|
rpc_url: Url,
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
impl Wallet {
|
2021-02-01 23:39:34 +00:00
|
|
|
pub async fn new(
|
|
|
|
electrum_rpc_url: Url,
|
|
|
|
electrum_http_url: Url,
|
|
|
|
network: bitcoin::Network,
|
2021-02-09 06:23:13 +00:00
|
|
|
wallet_dir: &Path,
|
|
|
|
private_key: PrivateKey,
|
2021-02-01 23:39:34 +00:00
|
|
|
) -> Result<Self> {
|
2021-02-15 05:04:51 +00:00
|
|
|
// Workaround for https://github.com/bitcoindevkit/rust-electrum-client/issues/47.
|
|
|
|
let config = electrum_client::ConfigBuilder::default().retry(2).build();
|
|
|
|
|
|
|
|
let client = Client::from_config(electrum_rpc_url.as_str(), config)
|
2021-02-01 23:39:34 +00:00
|
|
|
.map_err(|e| anyhow!("Failed to init electrum rpc client: {:?}", e))?;
|
|
|
|
|
2021-02-09 06:23:13 +00:00
|
|
|
let db = bdk::sled::open(wallet_dir)?.open_tree(SLED_TREE_NAME)?;
|
2021-02-01 23:39:34 +00:00
|
|
|
|
|
|
|
let bdk_wallet = bdk::Wallet::new(
|
2021-02-09 06:23:13 +00:00
|
|
|
bdk::template::P2WPKH(private_key),
|
2021-02-01 23:39:34 +00:00
|
|
|
None,
|
|
|
|
network,
|
|
|
|
db,
|
|
|
|
ElectrumBlockchain::from(client),
|
|
|
|
)?;
|
2021-01-05 03:08:36 +00:00
|
|
|
|
|
|
|
Ok(Self {
|
2021-02-01 23:39:34 +00:00
|
|
|
inner: Arc::new(Mutex::new(bdk_wallet)),
|
|
|
|
http_url: electrum_http_url,
|
|
|
|
rpc_url: electrum_rpc_url,
|
2021-01-05 03:08:36 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
pub async fn balance(&self) -> Result<Amount> {
|
2021-02-01 23:39:34 +00:00
|
|
|
let balance = self.inner.lock().await.get_balance()?;
|
|
|
|
Ok(Amount::from_sat(balance))
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
pub async fn new_address(&self) -> Result<Address> {
|
2021-02-25 01:51:30 +00:00
|
|
|
let address = self.inner.lock().await.get_new_address()?;
|
|
|
|
|
|
|
|
Ok(address)
|
2021-02-01 23:39:34 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
pub async fn get_tx(&self, txid: Txid) -> Result<Option<Transaction>> {
|
|
|
|
let tx = self.inner.lock().await.client().get_tx(&txid)?;
|
|
|
|
Ok(tx)
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
pub async fn transaction_fee(&self, txid: Txid) -> Result<Amount> {
|
2021-02-01 23:39:34 +00:00
|
|
|
let fees = self
|
2021-01-05 03:08:36 +00:00
|
|
|
.inner
|
2021-02-01 23:39:34 +00:00
|
|
|
.lock()
|
2021-01-05 03:08:36 +00:00
|
|
|
.await
|
2021-02-01 23:39:34 +00:00
|
|
|
.list_transactions(true)?
|
|
|
|
.iter()
|
|
|
|
.find(|tx| tx.txid == txid)
|
|
|
|
.ok_or_else(|| {
|
|
|
|
anyhow!("Could not find tx in bdk wallet when trying to determine fees")
|
2021-01-05 03:08:36 +00:00
|
|
|
})?
|
2021-02-01 23:39:34 +00:00
|
|
|
.fees;
|
|
|
|
|
|
|
|
Ok(Amount::from_sat(fees))
|
|
|
|
}
|
2021-01-05 03:08:36 +00:00
|
|
|
|
2021-02-01 23:39:34 +00:00
|
|
|
pub async fn sync_wallet(&self) -> Result<()> {
|
|
|
|
self.inner.lock().await.sync(noop_progress(), None)?;
|
|
|
|
Ok(())
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
|
2021-02-25 23:18:47 +00:00
|
|
|
pub async fn send_to_address(
|
2021-01-05 03:08:36 +00:00
|
|
|
&self,
|
2021-02-25 23:18:47 +00:00
|
|
|
address: Address,
|
|
|
|
amount: Amount,
|
2021-01-05 03:08:36 +00:00
|
|
|
) -> Result<PartiallySignedTransaction> {
|
2021-02-18 02:33:50 +00:00
|
|
|
let wallet = self.inner.lock().await;
|
|
|
|
|
|
|
|
let mut tx_builder = wallet.build_tx();
|
2021-02-25 23:18:47 +00:00
|
|
|
tx_builder.add_recipient(address.script_pubkey(), amount.as_sat());
|
|
|
|
tx_builder.fee_rate(FeeRate::from_sat_per_vb(5.0)); // todo: make dynamic
|
2021-02-18 02:33:50 +00:00
|
|
|
let (psbt, _details) = tx_builder.finish()?;
|
2021-02-25 23:18:47 +00:00
|
|
|
|
2021-01-05 03:08:36 +00:00
|
|
|
Ok(psbt)
|
|
|
|
}
|
2021-02-25 23:14:43 +00:00
|
|
|
|
|
|
|
pub async fn get_network(&self) -> bitcoin::Network {
|
|
|
|
self.inner.lock().await.network()
|
|
|
|
}
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl SignTxLock for Wallet {
|
|
|
|
async fn sign_tx_lock(&self, tx_lock: TxLock) -> Result<Transaction> {
|
2021-02-01 23:39:34 +00:00
|
|
|
let txid = tx_lock.txid();
|
|
|
|
tracing::debug!("signing tx lock: {}", txid);
|
2021-01-05 03:08:36 +00:00
|
|
|
let psbt = PartiallySignedTransaction::from(tx_lock);
|
2021-02-01 23:39:34 +00:00
|
|
|
let (signed_psbt, finalized) = self.inner.lock().await.sign(psbt, None)?;
|
|
|
|
if !finalized {
|
|
|
|
bail!("Could not finalize TxLock psbt")
|
|
|
|
}
|
|
|
|
let tx = signed_psbt.extract_tx();
|
|
|
|
tracing::debug!("signed tx lock: {}", txid);
|
2021-01-05 03:08:36 +00:00
|
|
|
Ok(tx)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl BroadcastSignedTransaction for Wallet {
|
|
|
|
async fn broadcast_signed_transaction(&self, transaction: Transaction) -> Result<Txid> {
|
2021-02-01 23:39:34 +00:00
|
|
|
tracing::debug!("attempting to broadcast tx: {}", transaction.txid());
|
|
|
|
self.inner.lock().await.broadcast(transaction.clone())?;
|
|
|
|
tracing::info!("Bitcoin tx broadcasted! TXID = {}", transaction.txid());
|
|
|
|
Ok(transaction.txid())
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl WatchForRawTransaction for Wallet {
|
2021-02-15 05:13:29 +00:00
|
|
|
async fn watch_for_raw_transaction(&self, txid: Txid) -> Result<Transaction> {
|
2021-02-01 23:39:34 +00:00
|
|
|
tracing::debug!("watching for tx: {}", txid);
|
2021-02-16 04:19:11 +00:00
|
|
|
let tx = retry(ConstantBackoff::new(Duration::from_secs(1)), || async {
|
|
|
|
let client = Client::new(self.rpc_url.as_ref())
|
2021-02-17 00:32:41 +00:00
|
|
|
.map_err(|err| backoff::Error::Permanent(Error::ElectrumClient(err)))?;
|
|
|
|
|
|
|
|
let tx = client.transaction_get(&txid).map_err(|err| match err {
|
|
|
|
electrum_client::Error::Protocol(err) => {
|
|
|
|
tracing::debug!("Received protocol error {} from Electrum, retrying...", err);
|
|
|
|
backoff::Error::Transient(Error::NotYetMined)
|
|
|
|
}
|
|
|
|
err => backoff::Error::Permanent(Error::ElectrumClient(err)),
|
|
|
|
})?;
|
2021-02-16 04:19:11 +00:00
|
|
|
|
|
|
|
Result::<_, backoff::Error<Error>>::Ok(tx)
|
2021-01-15 05:58:16 +00:00
|
|
|
})
|
|
|
|
.await
|
2021-02-17 00:09:09 +00:00
|
|
|
.context("transient errors to be retried")?;
|
2021-02-16 04:19:11 +00:00
|
|
|
|
|
|
|
Ok(tx)
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl GetRawTransaction for Wallet {
|
|
|
|
async fn get_raw_transaction(&self, txid: Txid) -> Result<Transaction> {
|
2021-02-01 23:39:34 +00:00
|
|
|
self.get_tx(txid)
|
|
|
|
.await?
|
|
|
|
.ok_or_else(|| anyhow!("Could not get raw tx with id: {}", txid))
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl GetBlockHeight for Wallet {
|
2021-02-15 05:13:29 +00:00
|
|
|
async fn get_block_height(&self) -> Result<BlockHeight> {
|
2021-02-16 00:48:46 +00:00
|
|
|
let url = blocks_tip_height_url(&self.http_url)?;
|
2021-01-15 05:58:16 +00:00
|
|
|
let height = retry(ConstantBackoff::new(Duration::from_secs(1)), || async {
|
2021-02-01 23:39:34 +00:00
|
|
|
let height = reqwest::Client::new()
|
2021-02-15 05:13:29 +00:00
|
|
|
.request(Method::GET, url.clone())
|
2021-02-01 23:39:34 +00:00
|
|
|
.send()
|
|
|
|
.await
|
|
|
|
.map_err(Error::Io)?
|
|
|
|
.text()
|
|
|
|
.await
|
|
|
|
.map_err(Error::Io)?
|
|
|
|
.parse::<u32>()
|
2021-02-15 05:13:29 +00:00
|
|
|
.map_err(|err| backoff::Error::Permanent(Error::Parse(err)))?;
|
2021-02-01 23:39:34 +00:00
|
|
|
Result::<_, backoff::Error<Error>>::Ok(height)
|
2021-01-15 05:58:16 +00:00
|
|
|
})
|
|
|
|
.await
|
2021-02-17 00:09:09 +00:00
|
|
|
.context("transient errors to be retried")?;
|
2021-01-05 03:08:36 +00:00
|
|
|
|
2021-02-15 05:13:29 +00:00
|
|
|
Ok(BlockHeight::new(height))
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl TransactionBlockHeight for Wallet {
|
2021-02-15 05:13:29 +00:00
|
|
|
async fn transaction_block_height(&self, txid: Txid) -> Result<BlockHeight> {
|
2021-02-16 00:48:46 +00:00
|
|
|
let url = tx_status_url(txid, &self.http_url)?;
|
2021-02-01 23:39:34 +00:00
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
|
|
struct TransactionStatus {
|
|
|
|
block_height: Option<u32>,
|
|
|
|
confirmed: bool,
|
|
|
|
}
|
2021-01-15 05:58:16 +00:00
|
|
|
let height = retry(ConstantBackoff::new(Duration::from_secs(1)), || async {
|
2021-02-01 23:39:34 +00:00
|
|
|
let resp = reqwest::Client::new()
|
2021-02-15 05:13:29 +00:00
|
|
|
.request(Method::GET, url.clone())
|
2021-02-01 23:39:34 +00:00
|
|
|
.send()
|
2021-01-05 03:08:36 +00:00
|
|
|
.await
|
2021-02-01 23:39:34 +00:00
|
|
|
.map_err(|err| backoff::Error::Transient(Error::Io(err)))?;
|
2021-01-05 03:08:36 +00:00
|
|
|
|
2021-02-01 23:39:34 +00:00
|
|
|
let tx_status: TransactionStatus = resp
|
|
|
|
.json()
|
|
|
|
.await
|
2021-02-17 00:09:09 +00:00
|
|
|
.map_err(|err| backoff::Error::Permanent(Error::JsonDeserialization(err)))?;
|
2021-02-01 23:39:34 +00:00
|
|
|
|
|
|
|
let block_height = tx_status
|
|
|
|
.block_height
|
|
|
|
.ok_or(backoff::Error::Transient(Error::NotYetMined))?;
|
2021-01-05 03:08:36 +00:00
|
|
|
|
|
|
|
Result::<_, backoff::Error<Error>>::Ok(block_height)
|
|
|
|
})
|
|
|
|
.await
|
2021-02-17 00:09:09 +00:00
|
|
|
.context("transient errors to be retried")?;
|
2021-01-05 03:08:36 +00:00
|
|
|
|
2021-02-15 05:13:29 +00:00
|
|
|
Ok(BlockHeight::new(height))
|
2021-01-05 03:08:36 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
impl WaitForTransactionFinality for Wallet {
|
2021-01-27 02:33:32 +00:00
|
|
|
async fn wait_for_transaction_finality(
|
|
|
|
&self,
|
|
|
|
txid: Txid,
|
|
|
|
execution_params: ExecutionParams,
|
|
|
|
) -> Result<()> {
|
2021-02-01 23:39:34 +00:00
|
|
|
tracing::debug!("waiting for tx finality: {}", txid);
|
2021-01-05 03:08:36 +00:00
|
|
|
// Divide by 4 to not check too often yet still be aware of the new block early
|
|
|
|
// on.
|
2021-01-27 02:33:32 +00:00
|
|
|
let mut interval = interval(execution_params.bitcoin_avg_block_time / 4);
|
2021-01-05 03:08:36 +00:00
|
|
|
|
|
|
|
loop {
|
2021-02-15 05:13:29 +00:00
|
|
|
let tx_block_height = self.transaction_block_height(txid).await?;
|
2021-02-01 23:39:34 +00:00
|
|
|
tracing::debug!("tx_block_height: {:?}", tx_block_height);
|
2021-02-15 05:13:29 +00:00
|
|
|
let block_height = self.get_block_height().await?;
|
2021-02-01 23:39:34 +00:00
|
|
|
tracing::debug!("latest_block_height: {:?}", block_height);
|
2021-02-19 06:09:53 +00:00
|
|
|
if let Some(confirmations) = block_height.checked_sub(
|
|
|
|
tx_block_height
|
|
|
|
.checked_sub(BlockHeight::new(1))
|
|
|
|
.expect("transaction must be included in block with height >= 1"),
|
|
|
|
) {
|
2021-02-01 23:39:34 +00:00
|
|
|
tracing::debug!("confirmations: {:?}", confirmations);
|
|
|
|
if u32::from(confirmations) >= execution_params.bitcoin_finality_confirmations {
|
2021-01-05 03:08:36 +00:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
interval.tick().await;
|
|
|
|
}
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-02-16 00:48:46 +00:00
|
|
|
fn tx_status_url(txid: Txid, base_url: &Url) -> Result<Url> {
|
|
|
|
let url = base_url.join(&format!("tx/{}/status", txid))?;
|
|
|
|
Ok(url)
|
|
|
|
}
|
|
|
|
|
|
|
|
fn blocks_tip_height_url(base_url: &Url) -> Result<Url> {
|
|
|
|
let url = base_url.join("blocks/tip/height")?;
|
|
|
|
Ok(url)
|
|
|
|
}
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
mod tests {
|
|
|
|
use crate::{
|
|
|
|
bitcoin::{
|
|
|
|
wallet::{blocks_tip_height_url, tx_status_url},
|
|
|
|
Txid,
|
|
|
|
},
|
|
|
|
cli::config::DEFAULT_ELECTRUM_HTTP_URL,
|
|
|
|
};
|
|
|
|
use reqwest::Url;
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn create_tx_status_url_from_default_base_url_success() {
|
|
|
|
let txid: Txid = Txid::default();
|
|
|
|
let base_url = Url::parse(DEFAULT_ELECTRUM_HTTP_URL).expect("Could not parse url");
|
|
|
|
let url = tx_status_url(txid, &base_url).expect("Could not create url");
|
|
|
|
let expected = format!("https://blockstream.info/testnet/api/tx/{}/status", txid);
|
|
|
|
assert_eq!(url.as_str(), expected);
|
|
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
fn create_block_tip_height_url_from_default_base_url_success() {
|
|
|
|
let base_url = Url::parse(DEFAULT_ELECTRUM_HTTP_URL).expect("Could not parse url");
|
|
|
|
let url = blocks_tip_height_url(&base_url).expect("Could not create url");
|
|
|
|
let expected = "https://blockstream.info/testnet/api/blocks/tip/height";
|
|
|
|
assert_eq!(url.as_str(), expected);
|
|
|
|
}
|
|
|
|
}
|