diff --git a/Cargo.lock b/Cargo.lock index 6f3986969..792079ba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,32 +130,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "baru" -version = "0.4.0" -source = "git+https://github.com/comit-network/baru.git?rev=43b7a057ed030c555ca3599c753ba7a66a703428#43b7a057ed030c555ca3599c753ba7a66a703428" -dependencies = [ - "aes-gcm-siv", - "anyhow", - "async-trait", - "bdk", - "bip32", - "conquer-once", - "elements", - "elements-miniscript", - "futures", - "hex", - "hkdf 0.11.0", - "itertools", - "log", - "rand 0.6.5", - "reqwest", - "rust_decimal", - "serde 1.0.126", - "sha2", - "thiserror", -] - [[package]] name = "base64" version = "0.13.0" @@ -303,7 +277,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "baru 0.3.0", + "baru", "bitcoin_hashes 0.9.6", "diesel", "diesel_migrations", @@ -415,6 +389,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "coin_selection" +version = "0.1.0" +dependencies = [ + "bdk", + "elements", + "estimate_transaction_size", + "thiserror", +] + [[package]] name = "conquer-once" version = "0.3.2" @@ -624,6 +608,10 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "estimate_transaction_size" +version = "0.1.0" + [[package]] name = "fnv" version = "1.0.7" @@ -889,16 +877,6 @@ dependencies = [ "hmac 0.10.1", ] -[[package]] -name = "hkdf" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" -dependencies = [ - "digest", - "hmac 0.11.0", -] - [[package]] name = "hmac" version = "0.10.1" @@ -2607,16 +2585,17 @@ version = "0.1.0" dependencies = [ "aes-gcm-siv", "anyhow", - "async-trait", - "baru 0.4.0", + "baru", "bip32", + "coin_selection", "conquer-once", "console_error_panic_hook", "elements", + "estimate_transaction_size", "futures", "getrandom 0.2.3", "hex", - "hkdf 0.10.0", + "hkdf", "itertools", "js-sys", "log", diff --git a/Cargo.toml b/Cargo.toml index a9788ba29..8029e2154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,7 @@ [workspace] members = [ "bobtimus", + "coin_selection", + "estimate_transaction_size", "extension/wallet", ] diff --git a/bobtimus/src/lib.rs b/bobtimus/src/lib.rs index 658ef7329..dfecbf491 100644 --- a/bobtimus/src/lib.rs +++ b/bobtimus/src/lib.rs @@ -339,11 +339,11 @@ where )?; let oracle_secret_key = elements::secp256k1_zkp::key::ONE_KEY; - let oracle_priv_key = elements::bitcoin::PrivateKey::new( + let oralce_priv_key = elements::bitcoin::PrivateKey::new( oracle_secret_key, elements::bitcoin::Network::Regtest, ); - let oracle_pk = PublicKey::from_private_key(&self.secp, &oracle_priv_key); + let oracle_pk = PublicKey::from_private_key(&self.secp, &oralce_priv_key); let timelock = days_to_unix_timestamp_timelock(loan_request.term, SystemTime::now())?; diff --git a/coin_selection/Cargo.toml b/coin_selection/Cargo.toml new file mode 100644 index 000000000..e6c8f9e42 --- /dev/null +++ b/coin_selection/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "coin_selection" +version = "0.1.0" +authors = ["CoBloX Team "] +edition = "2018" + +[dependencies] +bdk = { version = "0.4", default-features = false } +elements = "0.18" +estimate_transaction_size = { path = "../estimate_transaction_size" } +thiserror = "1" diff --git a/coin_selection/src/lib.rs b/coin_selection/src/lib.rs new file mode 100644 index 000000000..4699a5f2b --- /dev/null +++ b/coin_selection/src/lib.rs @@ -0,0 +1,360 @@ +use bdk::{ + database::{BatchOperations, Database}, + wallet::coin_selection::{ + BranchAndBoundCoinSelection, CoinSelectionAlgorithm, CoinSelectionResult, + }, +}; +use elements::{ + bitcoin::{Amount, Denomination}, + AssetId, OutPoint, Script, +}; +use estimate_transaction_size::avg_vbytes; + +/// Select a subset of `utxos` to cover the `target` amount. +/// +/// It makes use of a Branch and Bound coin selection algorithm +/// provided by `bdk`. +/// +/// Only supports P2PK, P2PKH and P2WPKH UTXOs. +pub fn coin_select( + utxos: Vec, + target: Amount, + fee_rate_sat_per_vbyte: f32, + fee_offset: Amount, +) -> Result { + let asset = utxos + .first() + .map(|utxo| utxo.asset) + .ok_or_else(|| Error::InsufficientFunds { + needed: target.as_sat(), + available: 0, + })?; + + if utxos.iter().any(|utxo| utxo.asset != asset) { + return Err(Error::HeterogeneousUtxos); + } + + let bdk_utxos = utxos + .iter() + .cloned() + .filter_map(|utxo| { + max_satisfaction_weight(&utxo.script_pubkey).map(|weight| (utxo, weight)) + }) + .map(|(utxo, weight)| (bdk::UTXO::from(utxo), weight)) + .collect(); + + // a change is a regular output + let size_of_change = avg_vbytes::OUTPUT; + + let CoinSelectionResult { + selected: selected_utxos, + fee_amount, + .. + } = BranchAndBoundCoinSelection::new(size_of_change) + .coin_select( + &DummyDb, + Vec::new(), + bdk_utxos, + bdk::FeeRate::from_sat_per_vb(fee_rate_sat_per_vbyte), + target.as_sat(), + fee_offset.as_sat() as f32, + ) + .map_err(|e| match e { + bdk::Error::InsufficientFunds { needed, available } => { + Error::InsufficientFunds { needed, available } + } + _ => Error::Bdk(e), + })?; + + let selected_utxos = selected_utxos + .iter() + .map(|bdk_utxo| { + utxos + .iter() + .find(|utxo| { + format!("{}", bdk_utxo.outpoint.txid) + == format!("{}", utxo.outpoint.txid.as_hash()) + && bdk_utxo.outpoint.vout == utxo.outpoint.vout + }) + .expect("same source of utxos") + }) + .cloned() + .collect(); + + let recommended_fee = + Amount::from_float_in(fee_amount.into(), Denomination::Satoshi).map_err(Error::ParseFee)?; + + Ok(Output { + coins: selected_utxos, + target_amount: target, + recommended_fee, + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Insufficient funds: needed at least {needed}, got {available}")] + InsufficientFunds { + /// Sats needed for some transaction + needed: u64, + /// Sats available for spending + available: u64, + }, + #[error("All UTXOs must have the same asset ID")] + HeterogeneousUtxos, + #[error("Failed to parse recommended fee: {0}")] + ParseFee(#[from] elements::bitcoin::util::amount::ParseAmountError), + #[error("Error from bdk: {0}")] + Bdk(#[from] bdk::Error), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Utxo { + pub outpoint: OutPoint, + pub value: u64, + pub script_pubkey: Script, + pub asset: AssetId, +} + +impl From for bdk::UTXO { + fn from(utxo: Utxo) -> Self { + let value = utxo.value; + let script_pubkey = utxo.script_pubkey.into_bytes(); + let script_pubkey = bdk::bitcoin::Script::from(script_pubkey); + + Self { + outpoint: bdk::bitcoin::OutPoint { + txid: format!("{}", utxo.outpoint.txid) + .parse() + .expect("txid to be a txid"), + vout: utxo.outpoint.vout, + }, + txout: bdk::bitcoin::TxOut { + value, + script_pubkey, + }, + keychain: bdk::KeychainKind::External, + } + } +} + +/// Result of running the coin selection algorithm succesfully. +#[derive(Debug)] +pub struct Output { + pub coins: Vec, + pub target_amount: Amount, + pub recommended_fee: Amount, +} + +impl Output { + pub fn recommended_change(&self) -> Amount { + self.selected_amount() - self.target_amount - self.recommended_fee + } + + pub fn selected_amount(&self) -> Amount { + let amount = self.coins.iter().fold(0, |acc, utxo| acc + utxo.value); + Amount::from_sat(amount) + } +} + +/// Return the maximum weight of a satisfying witness. +/// +/// Only supports P2PK, P2PKH and P2WPKH. +fn max_satisfaction_weight(script_pubkey: &Script) -> Option { + if script_pubkey.is_p2pk() { + Some(4 * (1 + 73)) + } else if script_pubkey.is_p2pkh() { + Some(4 * (1 + 73 + 34)) + } else if script_pubkey.is_v0_p2wpkh() { + Some(4 + 1 + 73 + 34) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use elements::{Address, Txid}; + use std::str::FromStr; + + #[test] + fn trivial_coin_selection() { + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::default(), + vout: 0, + }, + value: 100_000_000, + script_pubkey: Address::from_str("ert1qxzlkf3t275hwszualaf35spcfuq4s5tqtxj4tl") + .unwrap() + .script_pubkey(), + asset: AssetId::default(), + }; + + let target_amount = Amount::from_sat(90_000_000); + let selection = coin_select(vec![utxo.clone()], target_amount, 1.0, Amount::ZERO).unwrap(); + + assert!(selection.coins.len() == 1); + assert!(selection.coins.contains(&utxo)); + + assert_eq!( + selection.selected_amount() - target_amount - selection.recommended_fee, + selection.recommended_change() + ); + } +} + +/// A placeholder for the `database` argument required by +/// `CoinSelectionAlgorithm::coin_select`, but which is never actually +/// used in the trait implementation. +struct DummyDb; + +impl Database for DummyDb { + fn check_descriptor_checksum>( + &mut self, + _script_type: bdk::KeychainKind, + _bytes: B, + ) -> Result<(), bdk::Error> { + todo!() + } + + fn iter_script_pubkeys( + &self, + _script_type: Option, + ) -> Result, bdk::Error> { + todo!() + } + + fn iter_utxos(&self) -> Result, bdk::Error> { + todo!() + } + + fn iter_raw_txs(&self) -> Result, bdk::Error> { + todo!() + } + + fn iter_txs(&self, _include_raw: bool) -> Result, bdk::Error> { + todo!() + } + + fn get_script_pubkey_from_path( + &self, + _script_type: bdk::KeychainKind, + _child: u32, + ) -> Result, bdk::Error> { + todo!() + } + + fn get_path_from_script_pubkey( + &self, + _script: &bdk::bitcoin::Script, + ) -> Result, bdk::Error> { + todo!() + } + + fn get_utxo( + &self, + _outpoint: &bdk::bitcoin::OutPoint, + ) -> Result, bdk::Error> { + todo!() + } + + fn get_raw_tx( + &self, + _txid: &bdk::bitcoin::Txid, + ) -> Result, bdk::Error> { + todo!() + } + + fn get_tx( + &self, + _txid: &bdk::bitcoin::Txid, + _include_raw: bool, + ) -> Result, bdk::Error> { + todo!() + } + + fn get_last_index(&self, _script_type: bdk::KeychainKind) -> Result, bdk::Error> { + todo!() + } + + fn increment_last_index(&mut self, _script_type: bdk::KeychainKind) -> Result { + todo!() + } +} + +impl BatchOperations for DummyDb { + fn set_script_pubkey( + &mut self, + _script: &bdk::bitcoin::Script, + _script_type: bdk::KeychainKind, + _child: u32, + ) -> Result<(), bdk::Error> { + todo!() + } + + fn set_utxo(&mut self, _utxo: &bdk::UTXO) -> Result<(), bdk::Error> { + todo!() + } + + fn set_raw_tx(&mut self, _transaction: &bdk::bitcoin::Transaction) -> Result<(), bdk::Error> { + todo!() + } + + fn set_tx(&mut self, _transaction: &bdk::TransactionDetails) -> Result<(), bdk::Error> { + todo!() + } + + fn set_last_index( + &mut self, + _script_type: bdk::KeychainKind, + _value: u32, + ) -> Result<(), bdk::Error> { + todo!() + } + + fn del_script_pubkey_from_path( + &mut self, + _script_type: bdk::KeychainKind, + _child: u32, + ) -> Result, bdk::Error> { + todo!() + } + + fn del_path_from_script_pubkey( + &mut self, + _script: &bdk::bitcoin::Script, + ) -> Result, bdk::Error> { + todo!() + } + + fn del_utxo( + &mut self, + _outpoint: &bdk::bitcoin::OutPoint, + ) -> Result, bdk::Error> { + todo!() + } + + fn del_raw_tx( + &mut self, + _txid: &bdk::bitcoin::Txid, + ) -> Result, bdk::Error> { + todo!() + } + + fn del_tx( + &mut self, + _txid: &bdk::bitcoin::Txid, + _include_raw: bool, + ) -> Result, bdk::Error> { + todo!() + } + + fn del_last_index( + &mut self, + _script_type: bdk::KeychainKind, + ) -> Result, bdk::Error> { + todo!() + } +} diff --git a/estimate_transaction_size/Cargo.toml b/estimate_transaction_size/Cargo.toml new file mode 100644 index 000000000..0cd4f0713 --- /dev/null +++ b/estimate_transaction_size/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "estimate_transaction_size" +version = "0.1.0" +authors = ["CoBloX Team "] +edition = "2018" diff --git a/estimate_transaction_size/src/lib.rs b/estimate_transaction_size/src/lib.rs new file mode 100644 index 000000000..f1cea3c58 --- /dev/null +++ b/estimate_transaction_size/src/lib.rs @@ -0,0 +1,23 @@ +/// These constants have been reverse engineered through the following transactions: +/// +/// https://blockstream.info/liquid/tx/a17f4063b3a5fdf46a7012c82390a337e9a0f921933dccfb8a40241b828702f2 +/// https://blockstream.info/liquid/tx/d12ff4e851816908810c7abc839dd5da2c54ad24b4b52800187bee47df96dd5c +/// https://blockstream.info/liquid/tx/47e60a3bc5beed45a2cf9fb7a8d8969bab4121df98b0034fb0d44f6ed2d60c7d +/// +/// This gives us the following set of linear equations: +/// +/// - 1 in, 1 out, 1 fee = 1332 +/// - 1 in, 2 out, 1 fee = 2516 +/// - 2 in, 2 out, 1 fee = 2623 +/// +/// Which we can solve using wolfram alpha: https://www.wolframalpha.com/input/?i=1x+%2B+1y+%2B+1z+%3D+1332%2C+1x+%2B+2y+%2B+1z+%3D+2516%2C+2x+%2B+2y+%2B+1z+%3D+2623 +pub mod avg_vbytes { + pub const INPUT: u64 = 107; + pub const OUTPUT: u64 = 1184; + pub const FEE: u64 = 41; +} + +/// Estimate the virtual size of a transaction based on the number of inputs and outputs. +pub fn estimate_virtual_size(number_of_inputs: u64, number_of_outputs: u64) -> u64 { + number_of_inputs * avg_vbytes::INPUT + number_of_outputs * avg_vbytes::OUTPUT + avg_vbytes::FEE +} diff --git a/extension/wallet/Cargo.toml b/extension/wallet/Cargo.toml index 3f46c1e92..de031db50 100644 --- a/extension/wallet/Cargo.toml +++ b/extension/wallet/Cargo.toml @@ -13,12 +13,13 @@ default = ["console_error_panic_hook"] [dependencies] aes-gcm-siv = { version = "0.9", features = ["std"] } anyhow = "1" -async-trait = "0.1" -baru = { git = "https://github.com/comit-network/baru.git", rev = "43b7a057ed030c555ca3599c753ba7a66a703428" } +baru = "0.3" bip32 = { version = "0.2", features = ["secp256k1-ffi", "bip39"], default-features = false } +coin_selection = { path = "../../coin_selection" } conquer-once = "0.3" console_error_panic_hook = { version = "0.1.6", optional = true } elements = { version = "0.18", features = ["serde-feature"] } +estimate_transaction_size = { path = "../../estimate_transaction_size" } futures = "0.3" getrandom = { version = "0.2", features = ["wasm-bindgen", "js"] } hex = "0.4" diff --git a/extension/wallet/src/cache_storage.rs b/extension/wallet/src/cache_storage.rs index eb6ea1e49..9048c3665 100644 --- a/extension/wallet/src/cache_storage.rs +++ b/extension/wallet/src/cache_storage.rs @@ -6,10 +6,6 @@ pub struct CacheStorage { inner: Storage, } -// We assume that the javascript API's are threadsafe -unsafe impl Send for CacheStorage {} -unsafe impl Sync for CacheStorage {} - impl CacheStorage { pub fn new() -> Result { let local_storage = Storage::local_storage().with_context(|| "Could not open storage")?; diff --git a/extension/wallet/src/esplora.rs b/extension/wallet/src/esplora.rs index 13cd85d3d..21ef77724 100644 --- a/extension/wallet/src/esplora.rs +++ b/extension/wallet/src/esplora.rs @@ -1,153 +1,23 @@ -use crate::cache_storage::CacheStorage; +use crate::{cache_storage::CacheStorage, ESPLORA_API_URL}; use anyhow::{anyhow, bail, Context, Result}; -use async_trait::async_trait; -use baru::GetUtxos; use elements::{ encode::{deserialize, serialize_hex}, - Address, BlockHash, OutPoint, Transaction, TxOut, Txid, + Address, BlockHash, Transaction, Txid, }; -use futures::{stream::FuturesUnordered, TryStreamExt}; -use reqwest::{StatusCode, Url}; - -#[derive(Clone)] -pub struct EsploraClient { - base_url: Url, -} - -impl EsploraClient { - pub fn new(base_url: Url) -> Self { - Self { base_url } - } - - pub async fn fetch_transaction(&self, txid: Txid) -> Result { - fetch_transaction(self.base_url.clone(), txid).await - } - - pub async fn broadcast(&self, tx: Transaction) -> Result { - let esplora_url = self.base_url.clone(); - let esplora_url = esplora_url.join("tx")?; - let client = reqwest::Client::new(); - - let response = client - .post(esplora_url.clone()) - .body(serialize_hex(&tx)) - .send() - .await?; - - let code = response.status(); - - if !code.is_success() { - bail!("failed to successfully publish transaction"); - } - - let txid = response - .text() - .await? - .parse() - .context("failed to parse response body as txid")?; - - Ok(txid) - } - - /// Fetch transaction history for the specified address. - /// - /// Returns up to 50 mempool transactions plus the first 25 confirmed - /// transactions. See - /// https://github.com/blockstream/esplora/blob/master/API.md#get-addressaddresstxs - /// for more information. - pub async fn fetch_transaction_history(&self, address: &Address) -> Result> { - let path = format!("address/{}/txs", address); - let base_url = self.base_url.clone(); - let url = base_url.join(path.as_str())?; - let response = reqwest::get(url.clone()) - .await - .context("failed to fetch transaction history")?; - - if !response.status().is_success() { - let error_body = response.text().await?; - return Err(anyhow!( - "failed to fetch transaction history, esplora returned '{}' from '{}'", - error_body, - url - )); - } - - #[derive(serde::Deserialize)] - struct HistoryElement { - txid: Txid, - } - - let response = response - .json::>() - .await - .context("failed to deserialize response")?; - - Ok(response.iter().map(|elem| elem.txid).collect()) - } - - pub async fn get_fee_estimates(&self) -> Result { - let base_url = self.base_url.clone(); - let esplora_url = base_url.join("fee-estimates")?; - - let fee_estimates = reqwest::get(esplora_url.clone()) - .await - .with_context(|| format!("failed to GET {}", esplora_url))? - .json() - .await - .context("failed to deserialize fee estimates")?; - - Ok(fee_estimates) - } - - pub async fn get_block_height(&self) -> Result { - let base_url = self.base_url.clone(); - let esplora_url = base_url.join("/blocks/tip/height")?; - - let latest_block_height = reqwest::get(esplora_url.clone()) - .await - .with_context(|| format!("failed to GET {}", esplora_url))? - .json() - .await - .context("failed to deserialize latest block height")?; - - Ok(latest_block_height) - } -} - -#[async_trait(?Send)] -impl GetUtxos for EsploraClient { - async fn get_utxos(&self, address: Address) -> Result> { - let base_url = self.base_url.clone(); - let utxos = fetch_utxos(base_url.clone(), address).await?; - - let txouts = utxos - .into_iter() - .map(move |utxo| { - let base_url = base_url.clone(); - async move { - let mut tx = fetch_transaction(base_url, utxo.txid).await?; - let txout = tx.output.remove(utxo.vout as usize); - let utxo = OutPoint { - txid: utxo.txid, - vout: utxo.vout, - }; - Result::<_, anyhow::Error>::Ok((utxo, txout)) - } - }) - .collect::>() - .try_collect::>() - .await?; - - Ok(txouts) - } -} +use reqwest::StatusCode; +use wasm_bindgen::UnwrapThrowExt; /// Fetch the UTXOs of an address. /// /// UTXOs change over time and as such, this function never uses a cache. -async fn fetch_utxos(base_url: Url, address: Address) -> Result> { +pub async fn fetch_utxos(address: &Address) -> Result> { + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; + let path = format!("address/{}/utxo", address); - let esplora_url = base_url.join(path.as_str())?; + let esplora_url = esplora_url.join(path.as_str())?; let response = reqwest::get(esplora_url.clone()) .await .context("failed to fetch UTXOs")?; @@ -181,14 +51,57 @@ async fn fetch_utxos(base_url: Url, address: Address) -> Result> { Ok(utxos) } +/// Fetch transaction history for the specified address. +/// +/// Returns up to 50 mempool transactions plus the first 25 confirmed +/// transactions. See +/// https://github.com/blockstream/esplora/blob/master/API.md#get-addressaddresstxs +/// for more information. +pub async fn fetch_transaction_history(address: &Address) -> Result> { + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; + let path = format!("address/{}/txs", address); + let url = esplora_url.join(path.as_str())?; + let response = reqwest::get(url.clone()) + .await + .context("failed to fetch transaction history")?; + + if !response.status().is_success() { + let error_body = response.text().await?; + return Err(anyhow!( + "failed to fetch transaction history, esplora returned '{}' from '{}'", + error_body, + url + )); + } + + #[derive(serde::Deserialize)] + struct HistoryElement { + txid: Txid, + } + + let response = response + .json::>() + .await + .context("failed to deserialize response")?; + + Ok(response.iter().map(|elem| elem.txid).collect()) +} + /// Fetches a transaction. /// /// This function makes use of the browsers local storage to avoid spamming the underlying source. /// Transaction never change after they've been mined, hence we can cache those indefinitely. -pub async fn fetch_transaction(base_url: Url, txid: Txid) -> Result { +pub async fn fetch_transaction(txid: Txid) -> Result { + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; let cache = CacheStorage::new()?; let body = cache - .match_or_add(&format!("{}/tx/{}/hex", base_url, txid)) + .match_or_add(&format!("{}tx/{}/hex", esplora_url, txid)) .await? .text() .await?; @@ -196,6 +109,52 @@ pub async fn fetch_transaction(base_url: Url, txid: Txid) -> Result Ok(deserialize(&hex::decode(body.as_bytes())?)?) } +pub async fn broadcast(tx: Transaction) -> Result { + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; + let esplora_url = esplora_url.join("tx")?; + let client = reqwest::Client::new(); + + let response = client + .post(esplora_url.clone()) + .body(serialize_hex(&tx)) + .send() + .await?; + + let code = response.status(); + + if !code.is_success() { + bail!("failed to successfully publish transaction"); + } + + let txid = response + .text() + .await? + .parse() + .context("failed to parse response body as txid")?; + + Ok(txid) +} + +pub async fn get_fee_estimates() -> Result { + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; + let esplora_url = esplora_url.join("fee-estimates")?; + + let fee_estimates = reqwest::get(esplora_url.clone()) + .await + .with_context(|| format!("failed to GET {}", esplora_url))? + .json() + .await + .context("failed to deserialize fee estimates")?; + + Ok(fee_estimates) +} + /// The response object for the `/fee-estimates` endpoint. /// /// The key is the confirmation target (in number of blocks) and the value is the estimated feerate (in sat/vB). diff --git a/extension/wallet/src/lib.rs b/extension/wallet/src/lib.rs index 0d9bc08ab..842064bf8 100644 --- a/extension/wallet/src/lib.rs +++ b/extension/wallet/src/lib.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use bip32::{Language, Mnemonic}; use conquer_once::Lazy; -use elements::{bitcoin::util::amount::Amount, Address, Txid}; +use elements::{bitcoin::util::amount::Amount, Address, AddressParams, Txid}; use futures::lock::Mutex; use js_sys::Promise; use reqwest::Url; @@ -10,8 +10,6 @@ use rust_decimal::{prelude::ToPrimitive, Decimal, RoundingStrategy}; use wasm_bindgen::{prelude::*, JsCast}; use web_sys::window; -pub use baru::Chain; - #[macro_use] mod macros; @@ -22,7 +20,7 @@ mod logger; mod storage; mod wallet; -use crate::{esplora::EsploraClient, storage::Storage, wallet::*}; +use crate::{storage::Storage, wallet::*}; // TODO: make this configurable through extension option UI const DEFAULT_SAT_PER_VBYTE: u64 = 1; @@ -40,14 +38,14 @@ static CHAIN: Lazy> = Lazy::new(|| { .expect_throw("empty 'CHAIN'"), ) }); -static ESPLORA_CLIENT: Lazy> = Lazy::new(|| { - std::sync::Mutex::new(EsploraClient::new( +static ESPLORA_API_URL: Lazy> = Lazy::new(|| { + std::sync::Mutex::new( Storage::local_storage() .expect_throw("local storage to be available") .get_item::("ESPLORA_API_URL") .expect_throw("failed to get 'ESPLORA_API_URL'") .expect_throw("empty 'ESPLORA_API_URL'"), - )) + ) }); static BTC_ASSET_ID: Lazy> = Lazy::new(|| { std::sync::Mutex::new( @@ -108,16 +106,8 @@ pub async fn create_new_bip39_wallet( let mnemonic = Mnemonic::new(seed_words, Language::English) .map_err(|e| JsValue::from_str(format!("Could not parse seed words: {:?}", e).as_str()))?; - // todo: expose the chain param to the user map_err_from_anyhow!( - wallet::create_from_bip39( - name, - mnemonic, - password, - "Elements".to_string(), - &LOADED_WALLET - ) - .await + wallet::create_from_bip39(name, mnemonic, password, &LOADED_WALLET).await )?; Ok(JsValue::null()) @@ -131,9 +121,7 @@ pub async fn create_new_bip39_wallet( /// - the password is wrong #[wasm_bindgen] pub async fn load_existing_wallet(name: String, password: String) -> Result { - map_err_from_anyhow!( - wallet::load_existing(name, password, "elements".to_string(), &LOADED_WALLET).await - )?; + map_err_from_anyhow!(wallet::load_existing(name, password, &LOADED_WALLET).await)?; Ok(JsValue::null()) } @@ -155,17 +143,6 @@ pub async fn wallet_status(name: String) -> Result { Ok(status) } -/// Retrieve the latest block height from Esplora -#[wasm_bindgen] -pub async fn get_block_height() -> Result { - let client = ESPLORA_CLIENT.lock().expect_throw("can get lock"); - - let latest_block_height = map_err_from_anyhow!(client.get_block_height().await)?; - let latest_block_height = map_err_from_anyhow!(JsValue::from_serde(&latest_block_height))?; - - Ok(latest_block_height) -} - /// Get an address for the wallet with the given name. /// /// Fails if the wallet is currently not loaded. @@ -184,9 +161,7 @@ pub async fn get_address(name: String) -> Result { /// Fails if the wallet is currently not loaded or we cannot reach the block explorer for some reason. #[wasm_bindgen] pub async fn get_balances(name: String) -> Result { - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; - let balance_entries = - map_err_from_anyhow!(wallet::get_balances(&name, &LOADED_WALLET, &client).await)?; + let balance_entries = map_err_from_anyhow!(wallet::get_balances(&name, &LOADED_WALLET).await)?; let balance_entries = map_err_from_anyhow!(JsValue::from_serde(&balance_entries))?; Ok(balance_entries) @@ -214,9 +189,8 @@ pub async fn make_buy_create_swap_payload( usdt: String, ) -> Result { let usdt = map_err_from_anyhow!(parse_to_bitcoin_amount(usdt))?; - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; let payload = map_err_from_anyhow!( - wallet::make_buy_create_swap_payload(wallet_name, &LOADED_WALLET, usdt, &client).await + wallet::make_buy_create_swap_payload(wallet_name, &LOADED_WALLET, usdt).await )?; let payload = map_err_from_anyhow!(JsValue::from_serde(&payload))?; @@ -231,10 +205,9 @@ pub async fn make_sell_create_swap_payload( wallet_name: String, btc: String, ) -> Result { - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; let btc = map_err_from_anyhow!(parse_to_bitcoin_amount(btc))?; let payload = map_err_from_anyhow!( - wallet::make_sell_create_swap_payload(wallet_name, &LOADED_WALLET, btc, &client).await + wallet::make_sell_create_swap_payload(wallet_name, &LOADED_WALLET, btc).await )?; let payload = map_err_from_anyhow!(JsValue::from_serde(&payload))?; @@ -257,14 +230,12 @@ pub async fn make_loan_request( // TODO: Change the UI to handle SATs not BTC let collateral_in_btc = map_err_from_anyhow!(parse_to_bitcoin_amount(collateral))?; let fee_rate_in_sat = Amount::from_sat(map_err_from_anyhow!(u64::from_str(fee_rate.as_str()))?); - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; let loan_request = map_err_from_anyhow!( wallet::make_loan_request( wallet_name, &LOADED_WALLET, collateral_in_btc, fee_rate_in_sat, - &client ) .await )?; @@ -279,9 +250,7 @@ pub async fn make_loan_request( /// Returns the signed transaction. #[wasm_bindgen] pub async fn sign_loan(wallet_name: String) -> Result { - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; - let loan_tx = - map_err_from_anyhow!(wallet::sign_loan(wallet_name, &LOADED_WALLET, &client).await)?; + let loan_tx = map_err_from_anyhow!(wallet::sign_loan(wallet_name, &LOADED_WALLET).await)?; let loan_tx = map_err_from_anyhow!(JsValue::from_serde(&Transaction::from(loan_tx)))?; Ok(loan_tx) @@ -325,15 +294,9 @@ pub async fn sign_and_send_swap_transaction( transaction: JsValue, ) -> Result { let transaction: Transaction = map_err_from_anyhow!(transaction.into_serde())?; - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; let txid = map_err_from_anyhow!( - wallet::sign_and_send_swap_transaction( - wallet_name, - &LOADED_WALLET, - transaction.into(), - &client - ) - .await + wallet::sign_and_send_swap_transaction(wallet_name, &LOADED_WALLET, transaction.into()) + .await )?; let txid = map_err_from_anyhow!(JsValue::from_serde(&txid))?; @@ -349,9 +312,8 @@ pub async fn sign_and_send_swap_transaction( #[wasm_bindgen] pub async fn extract_trade(wallet_name: String, transaction: JsValue) -> Result { let transaction: Transaction = map_err_from_anyhow!(transaction.into_serde())?; - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; let trade = map_err_from_anyhow!( - wallet::extract_trade(wallet_name, &LOADED_WALLET, transaction.into(), &client).await + wallet::extract_trade(wallet_name, &LOADED_WALLET, transaction.into()).await )?; let trade = map_err_from_anyhow!(JsValue::from_serde(&trade))?; @@ -373,9 +335,8 @@ pub async fn extract_trade(wallet_name: String, transaction: JsValue) -> Result< #[wasm_bindgen] pub async fn extract_loan(wallet_name: String, loan_response: JsValue) -> Result { let loan_response = map_err_from_anyhow!(loan_response.into_serde())?; - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; let details = map_err_from_anyhow!( - wallet::extract_loan(wallet_name, &LOADED_WALLET, loan_response, &client).await + wallet::extract_loan(wallet_name, &LOADED_WALLET, loan_response).await )?; let details = map_err_from_anyhow!(JsValue::from_serde(&details))?; @@ -395,10 +356,8 @@ pub async fn get_open_loans() -> Result { #[wasm_bindgen] pub async fn repay_loan(wallet_name: String, loan_txid: String) -> Result { let loan_txid = map_err_from_anyhow!(Txid::from_str(&loan_txid))?; - let client = map_err_from_anyhow!(ESPLORA_CLIENT.lock())?; - let txid = map_err_from_anyhow!( - wallet::repay_loan(wallet_name, &LOADED_WALLET, loan_txid, &client).await - )?; + let txid = + map_err_from_anyhow!(wallet::repay_loan(wallet_name, &LOADED_WALLET, loan_txid).await)?; let txid = map_err_from_anyhow!(JsValue::from_serde(&txid))?; Ok(txid) @@ -429,10 +388,10 @@ fn handle_storage_update(event: web_sys::StorageEvent) -> Promise { } }; - let mut guard = ESPLORA_CLIENT + let mut guard = ESPLORA_API_URL .lock() .expect_throw("could not acquire lock."); - *guard = EsploraClient::new(esplora_api_url); + *guard = esplora_api_url; } (Some("LBTC_ASSET_ID"), Some(new_value)) => { let mut guard = BTC_ASSET_ID.lock().expect_throw("could not acquire lock"); @@ -451,6 +410,38 @@ fn handle_storage_update(event: web_sys::StorageEvent) -> Promise { Promise::resolve(&JsValue::null()) } +#[derive(Debug, Clone, Copy, PartialEq)] +enum Chain { + Elements, + Liquid, +} + +impl From for &AddressParams { + fn from(from: Chain) -> Self { + match from { + Chain::Elements => &AddressParams::ELEMENTS, + Chain::Liquid => &AddressParams::LIQUID, + } + } +} + +impl FromStr for Chain { + type Err = WrongChain; + + fn from_str(s: &str) -> Result { + let lowercase = s.to_ascii_lowercase(); + match lowercase.as_str() { + "elements" => Ok(Chain::Elements), + "liquid" => Ok(Chain::Liquid), + _ => Err(WrongChain(lowercase)), + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("Unsupported chain: {0}")] +struct WrongChain(String); + #[derive(serde::Serialize, serde::Deserialize)] struct Transaction { #[serde(with = "baru::loan::transaction_as_string")] diff --git a/extension/wallet/src/wallet.rs b/extension/wallet/src/wallet.rs index 74c20a353..2d74cd104 100644 --- a/extension/wallet/src/wallet.rs +++ b/extension/wallet/src/wallet.rs @@ -1,23 +1,44 @@ use crate::{ - assets::{self}, - DEFAULT_SAT_PER_VBYTE, + assets::{self, lookup}, + esplora, + esplora::Utxo, + CHAIN, DEFAULT_SAT_PER_VBYTE, +}; +use aes_gcm_siv::{ + aead::{Aead, NewAead}, + Aes256GcmSiv, }; use anyhow::{bail, Context, Result}; use elements::{ - bitcoin::{secp256k1::SecretKey, util::amount::Amount}, - Address, AssetId, OutPoint, Txid, + bitcoin::{ + self, + secp256k1::{SecretKey, SECP256K1}, + util::amount::Amount, + }, + confidential, + secp256k1_zkp::{rand, PublicKey}, + Address, AssetId, OutPoint, TxOut, Txid, +}; +use futures::{ + lock::{MappedMutexGuard, Mutex, MutexGuard}, + stream::FuturesUnordered, + StreamExt, TryStreamExt, }; -use futures::lock::{MappedMutexGuard, Mutex, MutexGuard}; +use hkdf::Hkdf; +use itertools::Itertools; +use rand::{thread_rng, Rng}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; +use sha2::{digest::generic_array::GenericArray, Sha256}; use std::{ convert::Infallible, fmt, ops::{Add, Sub}, str, }; +use wasm_bindgen::UnwrapThrowExt; -pub use baru::Wallet; +use bip32::{ExtendedPrivateKey, Prefix}; pub use create_new::{bip39_seed_words, create_from_bip39}; pub use extract_loan::{extract_loan, Error as ExtractLoanError}; pub use extract_trade::{extract_trade, Trade}; @@ -34,6 +55,7 @@ pub use make_loan_request::{make_loan_request, Error as MakeLoanRequestError}; pub use repay_loan::{repay_loan, Error as RepayLoanError}; pub(crate) use sign_and_send_swap_transaction::sign_and_send_swap_transaction; pub(crate) use sign_loan::sign_loan; +use std::str::FromStr; pub use unload_current::unload_current; pub use withdraw_everything_to::withdraw_everything_to; @@ -54,6 +76,30 @@ mod sign_loan; mod unload_current; mod withdraw_everything_to; +async fn get_txouts Result> + Copy>( + wallet: &Wallet, + filter_map: FM, +) -> Result> { + let address = wallet.get_address(); + + let utxos = esplora::fetch_utxos(&address).await?; + + let txouts = utxos + .into_iter() + .map(move |utxo| async move { + let mut tx = esplora::fetch_transaction(utxo.txid).await?; + let txout = tx.output.remove(utxo.vout as usize); + + filter_map(utxo, txout) + }) + .collect::>() + .filter_map(|r| std::future::ready(r.transpose())) + .try_collect::>() + .await?; + + Ok(txouts) +} + async fn current<'n, 'w>( name: &'n str, current_wallet: &'w Mutex>, @@ -61,13 +107,177 @@ async fn current<'n, 'w>( let mut guard = current_wallet.lock().await; match &mut *guard { - Some(wallet) if wallet.name() == name => {} + Some(wallet) if wallet.name == name => {} _ => bail!("wallet with name '{}' is currently not loaded", name), }; Ok(MutexGuard::map(guard, |w| w.as_mut().unwrap())) } +#[derive(Debug)] +pub struct Wallet { + name: String, + encryption_key: [u8; 32], + secret_key: SecretKey, + xprv: ExtendedPrivateKey, + sk_salt: [u8; 32], +} + +const SECRET_KEY_ENCRYPTION_NONCE: &[u8; 12] = b"SECRET_KEY!!"; + +impl Wallet { + pub fn initialize_new( + name: String, + password: String, + root_xprv: ExtendedPrivateKey, + ) -> Result { + let sk_salt = thread_rng().gen::<[u8; 32]>(); + + let encryption_key = Self::derive_encryption_key(&password, &sk_salt)?; + + // TODO: derive key according to some derivation path + let secret_key = root_xprv.to_bytes(); + + Ok(Self { + name, + encryption_key, + sk_salt, + secret_key: SecretKey::from_slice(&secret_key)?, + xprv: root_xprv, + }) + } + + pub fn initialize_existing( + name: String, + password: String, + xprv_ciphertext: String, + ) -> Result { + let mut parts = xprv_ciphertext.split('$'); + + let salt = parts.next().context("no salt in cipher text")?; + let xprv = parts.next().context("no secret key in cipher text")?; + + let mut sk_salt = [0u8; 32]; + hex::decode_to_slice(salt, &mut sk_salt).context("failed to decode salt as hex")?; + + let encryption_key = Self::derive_encryption_key(&password, &sk_salt)?; + + let cipher = Aes256GcmSiv::new(GenericArray::from_slice(&encryption_key)); + let nonce = GenericArray::from_slice(SECRET_KEY_ENCRYPTION_NONCE); + let xprv = cipher + .decrypt( + nonce, + hex::decode(xprv) + .context("failed to decode xpk as hex")? + .as_slice(), + ) + .context("failed to decrypt secret key")?; + + let xprv = String::from_utf8(xprv)?; + let root_xprv = ExtendedPrivateKey::from_str(xprv.as_str())?; + + // TODO: derive key according to some derivation path + let secret_key = root_xprv.to_bytes(); + + Ok(Self { + name, + encryption_key, + secret_key: SecretKey::from_slice(&secret_key)?, + xprv: root_xprv, + sk_salt, + }) + } + + pub fn get_public_key(&self) -> PublicKey { + PublicKey::from_secret_key(SECP256K1, &self.secret_key) + } + + pub fn get_address(&self) -> Address { + let chain = { + let guard = CHAIN.lock().expect_throw("can get lock"); + *guard + }; + let public_key = self.get_public_key(); + let blinding_key = PublicKey::from_secret_key(SECP256K1, &self.blinding_key()); + + Address::p2wpkh( + &bitcoin::PublicKey { + compressed: true, + key: public_key, + }, + Some(blinding_key), + chain.into(), + ) + } + + /// Encrypts the extended private key with the encryption key. + /// + /// # Choice of nonce + /// + /// We store the extended private key on disk and as such have to use a constant nonce, otherwise we would not be able to decrypt it again. + /// The encryption only happens once and as such, there is conceptually only one message and we are not "reusing" the nonce which would be insecure. + fn encrypted_xprv_key(&self) -> Result> { + let cipher = Aes256GcmSiv::new(GenericArray::from_slice(&self.encryption_key)); + let xprv = &self.xprv.to_string(Prefix::XPRV); + let enc_sk = cipher + .encrypt( + GenericArray::from_slice(SECRET_KEY_ENCRYPTION_NONCE), + xprv.as_bytes(), + ) + .context("failed to encrypt secret key")?; + + Ok(enc_sk) + } + + /// Derive the blinding key. + /// + /// # Choice of salt + /// + /// We choose to not add a salt because the ikm is already a randomly-generated, secret value with decent entropy. + /// + /// # Choice of ikm + /// + /// We derive the blinding key from the secret key to avoid having to store two secret values on disk. + /// + /// # Choice of info + /// + /// We choose to tag the derived key with `b"BLINDING_KEY"` in case we ever want to derive something else from the secret key. + fn blinding_key(&self) -> SecretKey { + let h = Hkdf::::new(None, self.secret_key.as_ref()); + + let mut bk = [0u8; 32]; + h.expand(b"BLINDING_KEY", &mut bk) + .expect("output length aligns with sha256"); + + SecretKey::from_slice(bk.as_ref()).expect("always a valid secret key") + } + + /// Derive the encryption key from the wallet's password and a salt. + /// + /// # Choice of salt + /// + /// The salt of HKDF can be public or secret and while it can operate without a salt, it is better to pass a salt value [0]. + /// + /// # Choice of ikm + /// + /// The user's password is our input key material. The stronger the password, the better the resulting encryption key. + /// + /// # Choice of info + /// + /// HKDF can operate without `info`, however, it is useful to "tag" the derived key with its usage. + /// In our case, we use the encryption key to encrypt the secret key and as such, tag it with `b"ENCRYPTION_KEY"`. + /// + /// [0]: https://tools.ietf.org/html/rfc5869#section-3.1 + fn derive_encryption_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> { + let h = Hkdf::::new(Some(salt), password.as_bytes()); + let mut enc_key = [0u8; 32]; + h.expand(b"ENCRYPTION_KEY", &mut enc_key) + .context("failed to derive encryption key")?; + + Ok(enc_key) + } +} + #[derive(Default)] pub struct ListOfWallets(Vec); @@ -112,7 +322,64 @@ pub struct SwapUtxo { pub blinding_key: SecretKey, } -pub use baru::BalanceEntry; +/// A single balance entry as returned by [`get_balances`]. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct BalanceEntry { + pub asset: AssetId, + pub ticker: String, + pub value: Decimal, +} + +impl BalanceEntry { + pub fn for_asset(asset: AssetId, ticker: String, value: u64, precision: u32) -> Self { + let mut decimal = Decimal::from(value); + decimal + .set_scale(precision) + .expect("precision must be < 28"); + + Self { + asset, + ticker, + value: decimal, + } + } +} + +/// A pure function to compute the balances of the wallet given a set of [`TxOut`]s. +fn compute_balances(wallet: &Wallet, txouts: &[TxOut]) -> Vec { + let grouped_txouts = txouts + .iter() + .filter_map(|utxo| match utxo { + TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + .. + } => Some((*asset, *value)), + txout => match txout.unblind(SECP256K1, wallet.blinding_key()) { + Ok(unblinded_txout) => Some((unblinded_txout.asset, unblinded_txout.value)), + Err(e) => { + log::warn!("failed to unblind txout: {}", e); + None + } + }, + }) + .into_group_map(); + + grouped_txouts + .into_iter() + .filter_map(|(asset, utxos)| { + let total_sum = utxos.into_iter().sum(); + let (ticker, precision) = lookup(asset)?; + + Some(BalanceEntry::for_asset( + asset, + ticker.to_owned(), + total_sum, + precision as u32, + )) + }) + .collect() +} #[derive(Clone, Deserialize, Serialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] @@ -124,18 +391,18 @@ pub struct TradeSide { } impl TradeSide { - fn new_sell(asset: AssetId, amount: u64, current_balance: u64) -> Result { + fn new_sell(asset: AssetId, amount: u64, current_balance: Decimal) -> Result { Self::new(asset, amount, current_balance, Decimal::sub) } - fn new_buy(asset: AssetId, amount: u64, current_balance: u64) -> Result { + fn new_buy(asset: AssetId, amount: u64, current_balance: Decimal) -> Result { Self::new(asset, amount, current_balance, Decimal::add) } fn new( asset: AssetId, amount: u64, - current_balance: u64, + current_balance: Decimal, balance_after: impl Fn(Decimal, Decimal) -> Decimal, ) -> Result { let (ticker, precision) = assets::lookup(asset).context("asset not found")?; @@ -145,11 +412,6 @@ impl TradeSide { .set_scale(precision as u32) .expect("precision must be < 28"); - let current_balance = Decimal::from(current_balance); - amount - .set_scale(precision as u32) - .expect("precision must be < 28"); - Ok(Self { ticker: ticker.to_owned(), amount, @@ -164,8 +426,6 @@ impl TradeSide { pub struct LoanDetails { pub collateral: TradeSide, pub principal: TradeSide, - // TODO: This should be a u64 (sats) to prevent loss of precision when converting to a double in - // javascript land pub principal_repayment: Decimal, // TODO: Express as target date or number of days instead? pub term: u32, @@ -177,10 +437,10 @@ impl LoanDetails { pub fn new( collateral_asset: AssetId, collateral_amount: Amount, - collateral_balance: u64, + collateral_balance: Decimal, principal_asset: AssetId, principal_amount: Amount, - principal_balance: u64, + principal_balance: Decimal, timelock: u32, txid: Txid, ) -> Result { @@ -221,14 +481,7 @@ mod browser_tests { current_wallet: &Mutex>, ) -> Result<()> { let mnemonic = Mnemonic::new("globe favorite camp draw action kid soul junk space soda genre vague name brisk female circle equal fix decade gloom elbow address genius noodle", Language::English).unwrap(); - create_from_bip39( - name, - mnemonic, - password, - "elements".to_string(), - current_wallet, - ) - .await + create_from_bip39(name, mnemonic, password, current_wallet).await } fn set_elements_chain_in_local_storage() { @@ -313,14 +566,9 @@ mod browser_tests { .await .unwrap(); - let error = load_existing( - "wallet-4".to_owned(), - "foo".to_owned(), - "elements".to_string(), - ¤t_wallet, - ) - .await - .unwrap_err(); + let error = load_existing("wallet-4".to_owned(), "foo".to_owned(), ¤t_wallet) + .await + .unwrap_err(); assert_eq!( error.to_string(), @@ -337,14 +585,9 @@ mod browser_tests { .unwrap(); unload_current(¤t_wallet).await; - let error = load_existing( - "wallet-6".to_owned(), - "bar".to_owned(), - "elements".to_string(), - ¤t_wallet, - ) - .await - .unwrap_err(); + let error = load_existing("wallet-6".to_owned(), "bar".to_owned(), ¤t_wallet) + .await + .unwrap_err(); assert_eq!(error.to_string(), "bad password for wallet 'wallet-6'"); } @@ -353,14 +596,9 @@ mod browser_tests { pub async fn cannot_load_wallet_that_doesnt_exist() { let current_wallet = Mutex::default(); - let error = load_existing( - "foobar".to_owned(), - "bar".to_owned(), - "elements".to_string(), - ¤t_wallet, - ) - .await - .unwrap_err(); + let error = load_existing("foobar".to_owned(), "bar".to_owned(), ¤t_wallet) + .await + .unwrap_err(); assert_eq!(error.to_string(), "wallet 'foobar' does not exist"); } @@ -401,24 +639,19 @@ mod browser_tests { let guard = current_wallet.lock().await; let wallet = guard.as_ref().unwrap(); - wallet.secret_key() + wallet.secret_key.clone() }; unload_current(¤t_wallet).await; - load_existing( - "wallet-9".to_owned(), - "foo".to_owned(), - "elements".to_string(), - ¤t_wallet, - ) - .await - .unwrap(); + load_existing("wallet-9".to_owned(), "foo".to_owned(), ¤t_wallet) + .await + .unwrap(); let loaded_sk = { let guard = current_wallet.lock().await; let wallet = guard.as_ref().unwrap(); - wallet.secret_key() + wallet.secret_key.clone() }; assert_eq!(initial_sk, loaded_sk); diff --git a/extension/wallet/src/wallet/create_new.rs b/extension/wallet/src/wallet/create_new.rs index 8b3200a40..f30a1527a 100644 --- a/extension/wallet/src/wallet/create_new.rs +++ b/extension/wallet/src/wallet/create_new.rs @@ -4,7 +4,6 @@ use futures::lock::Mutex; use crate::{ storage::Storage, wallet::{ListOfWallets, Wallet}, - Chain, }; use bip32::{ExtendedPrivateKey, Language, Mnemonic}; use rand::{thread_rng, RngCore}; @@ -19,7 +18,6 @@ pub async fn create_from_bip39( name: String, mnemonic: Mnemonic, password: String, - chain: String, current_wallet: &Mutex>, ) -> Result<()> { let storage = Storage::local_storage()?; @@ -45,17 +43,14 @@ pub async fn create_from_bip39( let secret_key_seed = mnemonic.to_seed(password.as_str()); let xprv = ExtendedPrivateKey::new(secret_key_seed)?; - let chain = chain.parse::()?; - #[allow(deprecated)] - let new_wallet = Wallet::initialize_new(name.clone(), password, xprv, chain)?; + let new_wallet = Wallet::initialize_new(name.clone(), password, xprv)?; storage.set_item(&format!("wallets.{}.password", name), hashed_password)?; - #[allow(deprecated)] storage.set_item( &format!("wallets.{}.xprv", name), format!( "{}${}", - hex::encode(new_wallet.sk_salt()), + hex::encode(new_wallet.sk_salt), hex::encode(new_wallet.encrypted_xprv_key()?) ), )?; diff --git a/extension/wallet/src/wallet/extract_loan.rs b/extension/wallet/src/wallet/extract_loan.rs index a88a296dc..03a01008e 100644 --- a/extension/wallet/src/wallet/extract_loan.rs +++ b/extension/wallet/src/wallet/extract_loan.rs @@ -1,7 +1,6 @@ use crate::{ - esplora::EsploraClient, storage::Storage, - wallet::{current, Wallet}, + wallet::{compute_balances, current, get_txouts, Wallet}, LoanDetails, BTC_ASSET_ID, USDT_ASSET_ID, }; use baru::loan::{Borrower0, LoanResponse}; @@ -13,7 +12,6 @@ pub async fn extract_loan( name: String, current_wallet: &Mutex>, loan_response: LoanResponse, - client: &EsploraClient, ) -> Result { let btc_asset_id = { let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); @@ -24,12 +22,21 @@ pub async fn extract_loan( *guard }; - let mut wallet = current(&name, current_wallet) + let wallet = current(&name, current_wallet) .await .map_err(Error::LoadWallet)?; - wallet.sync(client).await.map_err(Error::SyncWallet)?; - let balances = wallet.compute_balances(); + let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))) + .await + .map_err(Error::GetTxOuts)?; + let balances = compute_balances( + &wallet, + &txouts + .iter() + .map(|(_, txout)| txout) + .cloned() + .collect::>(), + ); let storage = Storage::local_storage().map_err(Error::Storage)?; let borrower = storage @@ -94,6 +101,8 @@ pub enum Error { LoanResponseDeserialization(#[from] serde_json::Error), #[error("Wallet is not loaded: {0}")] LoadWallet(anyhow::Error), + #[error("Failed to get transaction outputs: {0}")] + GetTxOuts(anyhow::Error), #[error("Storage error: {0}")] Storage(anyhow::Error), #[error("Failed to load item from storage: {0}")] @@ -112,6 +121,4 @@ pub enum Error { InsufficientCollateral, #[error("Failed to build loan details: {0}")] LoanDetails(anyhow::Error), - #[error("Could not sync wallet: {0}")] - SyncWallet(anyhow::Error), } diff --git a/extension/wallet/src/wallet/extract_trade.rs b/extension/wallet/src/wallet/extract_trade.rs index 047ff8435..8d19ceec6 100644 --- a/extension/wallet/src/wallet/extract_trade.rs +++ b/extension/wallet/src/wallet/extract_trade.rs @@ -1,10 +1,9 @@ use crate::{ - esplora::EsploraClient, - wallet::{current, Wallet}, + wallet::{compute_balances, current, get_txouts, Wallet}, TradeSide, }; use anyhow::{bail, Context, Result}; -use elements::Transaction; +use elements::{confidential, secp256k1_zkp::SECP256K1, Transaction, TxOut}; use futures::lock::Mutex; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -14,14 +13,51 @@ pub async fn extract_trade( name: String, current_wallet: &Mutex>, transaction: Transaction, - client: &EsploraClient, ) -> Result { - let mut wallet = current(&name, current_wallet).await?; - wallet.sync(&*client).await?; + let wallet = current(&name, current_wallet).await?; - let balances = wallet.compute_balances(); + let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))).await?; + let balances = compute_balances( + &wallet, + &txouts + .iter() + .map(|(_, txout)| txout) + .cloned() + .collect::>(), + ); - let our_inputs = wallet.find_our_input_indices_in_transaction(&transaction)?; + let blinding_key = wallet.blinding_key(); + + let our_inputs = transaction + .input + .iter() + .filter_map(|txin| { + txouts + .iter() + .map(|(utxo, txout)| { + let is_ours = utxo.txid == txin.previous_output.txid + && utxo.vout == txin.previous_output.vout; + if !is_ours { + return Ok(None); + } + + Ok(match txout { + TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + .. + } => Some((*asset, *value)), + txout => { + let unblinded = txout.unblind(SECP256K1, blinding_key)?; + + Some((unblinded.asset, unblinded.value)) + } + }) + }) + .find_map(|res| res.transpose()) + }) + .collect::>>() + .context("failed to unblind one of our inputs")?; let (sell_asset, sell_input) = our_inputs .into_iter() @@ -31,18 +67,47 @@ pub async fn extract_trade( .exactly_one() .context("expected single input asset type")?; - let our_outputs = wallet.find_our_ouput_indices_in_transaction(&transaction); + let our_address = wallet.get_address(); + let our_outputs = transaction + .output + .iter() + .filter_map(|txout| match txout { + TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + script_pubkey, + .. + } if script_pubkey == &our_address.script_pubkey() => Some((*asset, *value)), + TxOut { + asset: confidential::Asset::Explicit(_), + value: confidential::Value::Explicit(_), + .. + } => { + log::debug!( + "ignoring explicit outputs that do not pay to our address, including fees" + ); + None + } + txout => match txout.unblind(SECP256K1, blinding_key) { + Ok(unblinded) => Some((unblinded.asset, unblinded.value)), + _ => None, + }, + }) + .into_grouping_map() + .fold(0, |sum, _asset, value| sum + value) + .into_iter() + .collect_tuple() + .context("wrong number of outputs, expected 2")?; - let ((buy_asset, buy_amount), change_amount) = match our_outputs.as_slice() { - [(change_asset, change_amount), buy_output] if *change_asset == sell_asset => { + let ((buy_asset, buy_amount), change_amount) = match our_outputs { + ((change_asset, change_amount), buy_output) if change_asset == sell_asset => { (buy_output, change_amount) } - [buy_output, (change_asset, change_amount)] if *change_asset == sell_asset => { + (buy_output, (change_asset, change_amount)) if change_asset == sell_asset => { (buy_output, change_amount) } - &_ => bail!("no output corresponds to the sell asset"), + _ => bail!("no output corresponds to the sell asset"), }; - let sell_amount = sell_input - change_amount; let sell_balance = balances @@ -59,7 +124,7 @@ pub async fn extract_trade( let buy_balance = balances .iter() .find_map(|entry| { - if entry.asset == *buy_asset { + if entry.asset == buy_asset { Some(entry.value) } else { None @@ -69,7 +134,7 @@ pub async fn extract_trade( Ok(Trade { sell: TradeSide::new_sell(sell_asset, sell_amount, sell_balance)?, - buy: TradeSide::new_buy(*buy_asset, *buy_amount, buy_balance)?, + buy: TradeSide::new_buy(buy_asset, buy_amount, buy_balance)?, }) } diff --git a/extension/wallet/src/wallet/get_balances.rs b/extension/wallet/src/wallet/get_balances.rs index baf6b454c..3629aab8f 100644 --- a/extension/wallet/src/wallet/get_balances.rs +++ b/extension/wallet/src/wallet/get_balances.rs @@ -1,19 +1,16 @@ -use crate::{ - esplora::EsploraClient, - wallet::{current, BalanceEntry, Wallet}, -}; +use crate::wallet::{compute_balances, current, get_txouts, BalanceEntry, Wallet}; use anyhow::Result; use futures::lock::Mutex; pub async fn get_balances( name: &str, current_wallet: &Mutex>, - client: &EsploraClient, ) -> Result> { - let mut wallet = current(name, current_wallet).await?; - wallet.sync(client).await?; + let wallet = current(name, current_wallet).await?; - let balances = wallet.compute_balances(); + let txouts = get_txouts(&wallet, |_, txout| Ok(Some(txout))).await?; + + let balances = compute_balances(&wallet, &txouts); Ok(balances) } diff --git a/extension/wallet/src/wallet/get_status.rs b/extension/wallet/src/wallet/get_status.rs index bd182665f..e72d3a22f 100644 --- a/extension/wallet/src/wallet/get_status.rs +++ b/extension/wallet/src/wallet/get_status.rs @@ -17,7 +17,7 @@ pub async fn get_status( let exists = wallets.has(&name); let guard = current_wallet.lock().await; - let loaded = guard.as_ref().map_or(false, |w| w.name() == name); + let loaded = guard.as_ref().map_or(false, |w| w.name == name); Ok(WalletStatus { loaded, exists }) } diff --git a/extension/wallet/src/wallet/get_transaction_history.rs b/extension/wallet/src/wallet/get_transaction_history.rs index c4df6894a..2ad7922e4 100644 --- a/extension/wallet/src/wallet/get_transaction_history.rs +++ b/extension/wallet/src/wallet/get_transaction_history.rs @@ -2,24 +2,18 @@ use anyhow::Result; use elements::Txid; use futures::lock::Mutex; -use crate::{ - wallet::{current, Wallet}, - ESPLORA_CLIENT, -}; -use wasm_bindgen::UnwrapThrowExt; +use crate::{esplora, wallet::current, Wallet}; pub async fn get_transaction_history( name: String, current_wallet: &Mutex>, ) -> Result> { - let client = ESPLORA_CLIENT.lock().expect_throw("can get lock"); - let wallet = current(&name, current_wallet).await?; // We have a single address, so looking for the transaction // history of said address is sufficient let address = wallet.get_address(); - let history = client.fetch_transaction_history(&address).await?; + let history = esplora::fetch_transaction_history(&address).await?; Ok(history) } diff --git a/extension/wallet/src/wallet/load_existing.rs b/extension/wallet/src/wallet/load_existing.rs index 38bfd0415..0ac99ff6a 100644 --- a/extension/wallet/src/wallet/load_existing.rs +++ b/extension/wallet/src/wallet/load_existing.rs @@ -3,22 +3,20 @@ use crate::{ wallet::{ListOfWallets, Wallet}, }; use anyhow::{bail, Context, Result}; -use baru::Chain; use futures::lock::Mutex; pub async fn load_existing( name: String, password: String, - chain: String, current_wallet: &Mutex>, ) -> Result<()> { let mut guard = current_wallet.lock().await; - if let Some(wallet) = &*guard { + if let Some(Wallet { name: loaded, .. }) = &*guard { bail!( "cannot load wallet '{}' because wallet '{}' is currently loaded", name, - wallet.name() + loaded ) } @@ -42,9 +40,7 @@ pub async fn load_existing( .get_item::(&format!("wallets.{}.xprv", name))? .context("no xprv key for wallet")?; - let chain = chain.parse::()?; - #[allow(deprecated)] - let wallet = Wallet::initialize_existing(name, password, xprv_ciphertext, chain)?; + let wallet = Wallet::initialize_existing(name, password, xprv_ciphertext)?; guard.replace(wallet); diff --git a/extension/wallet/src/wallet/make_create_swap_payload.rs b/extension/wallet/src/wallet/make_create_swap_payload.rs index fbb1f991b..092f35162 100644 --- a/extension/wallet/src/wallet/make_create_swap_payload.rs +++ b/extension/wallet/src/wallet/make_create_swap_payload.rs @@ -1,10 +1,10 @@ use crate::{ - esplora::EsploraClient, - wallet::{current, CreateSwapPayload, SwapUtxo, Wallet}, + wallet::{current, get_txouts, CreateSwapPayload, SwapUtxo, Wallet}, BTC_ASSET_ID, USDT_ASSET_ID, }; -use baru::avg_vbytes; -use elements::{bitcoin::Amount, AssetId}; +use coin_selection::{self, coin_select}; +use elements::{bitcoin::Amount, secp256k1_zkp::SECP256K1, AssetId, OutPoint}; +use estimate_transaction_size::avg_vbytes; use futures::lock::Mutex; use wasm_bindgen::UnwrapThrowExt; @@ -12,7 +12,6 @@ pub async fn make_buy_create_swap_payload( name: String, current_wallet: &Mutex>, sell_amount: Amount, - client: &EsploraClient, ) -> Result { let btc_asset_id = { let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); @@ -29,7 +28,6 @@ pub async fn make_buy_create_swap_payload( sell_amount, usdt_asset_id, btc_asset_id, - client, ) .await } @@ -38,7 +36,6 @@ pub async fn make_sell_create_swap_payload( name: String, current_wallet: &Mutex>, sell_amount: Amount, - client: &EsploraClient, ) -> Result { let btc_asset_id = { let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); @@ -50,7 +47,6 @@ pub async fn make_sell_create_swap_payload( sell_amount, btc_asset_id, btc_asset_id, - client, ) .await } @@ -61,41 +57,73 @@ async fn make_create_swap_payload( sell_amount: Amount, sell_asset: AssetId, fee_asset: AssetId, - client: &EsploraClient, ) -> Result { + let wallet = current(&name, current_wallet) + .await + .map_err(Error::LoadWallet)?; + let blinding_key = wallet.blinding_key(); + + let utxos = get_txouts(&wallet, |utxo, txout| { + Ok({ + let unblinded_txout = txout.unblind(SECP256K1, blinding_key)?; + let outpoint = OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }; + let candidate_asset = unblinded_txout.asset; + + if candidate_asset == sell_asset { + Some(coin_selection::Utxo { + outpoint, + value: unblinded_txout.value, + script_pubkey: txout.script_pubkey, + asset: candidate_asset, + }) + } else { + log::debug!( + "utxo {} with asset id {} is not the sell asset, ignoring", + outpoint, + candidate_asset + ); + None + } + }) + }) + .await + .map_err(Error::GetTxOuts)?; + let (bobs_fee_rate, fee_offset) = if fee_asset == sell_asset { // Bob currently hardcodes a fee-rate of 1 sat / vbyte, hence // there is no need for us to perform fee estimation. Later // on, both parties should probably agree on a block-target // and use the same estimation service. - let bobs_fee_rate = Amount::ONE_SAT; + let bobs_fee_rate = Amount::from_sat(1); let fee_offset = calculate_fee_offset(bobs_fee_rate); - (bobs_fee_rate.as_sat() as f32, fee_offset) + (bobs_fee_rate, fee_offset) } else { - (0.0, Amount::ZERO) + (Amount::ZERO, Amount::ZERO) }; - let mut wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - - wallet.sync(&*client).await.map_err(Error::SyncWallet)?; - - let inputs = wallet - .coin_selection(sell_amount, sell_asset, bobs_fee_rate, fee_offset) - .map_err(Error::CoinSelection)?; + let output = coin_select( + utxos, + sell_amount, + bobs_fee_rate.as_sat() as f32, + fee_offset, + ) + .map_err(Error::CoinSelection)?; Ok(CreateSwapPayload { address: wallet.get_address(), - alice_inputs: inputs + alice_inputs: output + .coins .into_iter() .map(|utxo| SwapUtxo { - outpoint: utxo.txin, - blinding_key: utxo.blinding_key, + outpoint: utxo.outpoint, + blinding_key, }) .collect(), - amount: sell_amount, + amount: output.target_amount, }) } @@ -104,9 +132,9 @@ pub enum Error { #[error("Wallet is not loaded: {0}")] LoadWallet(anyhow::Error), #[error("Coin selection: {0}")] - CoinSelection(anyhow::Error), - #[error("Could not sync wallet: {0}")] - SyncWallet(anyhow::Error), + CoinSelection(coin_selection::Error), + #[error("Failed to get transaction outputs: {0}")] + GetTxOuts(anyhow::Error), } /// Calculate the fee offset required for the coin selection algorithm. diff --git a/extension/wallet/src/wallet/make_loan_request.rs b/extension/wallet/src/wallet/make_loan_request.rs index b001d6426..36d691417 100644 --- a/extension/wallet/src/wallet/make_loan_request.rs +++ b/extension/wallet/src/wallet/make_loan_request.rs @@ -1,14 +1,15 @@ use crate::{ - esplora::EsploraClient, storage::Storage, - wallet::{current, Wallet}, + wallet::{current, get_txouts, Wallet}, BTC_ASSET_ID, USDT_ASSET_ID, }; -use baru::{avg_vbytes, input::Input, loan::Borrower0}; -use elements::{ - bitcoin::{util::amount::Amount, PublicKey}, - Address, +use baru::{ + input::Input, + loan::{Borrower0, LoanRequest}, }; +use coin_selection::{self, coin_select}; +use elements::{bitcoin::util::amount::Amount, secp256k1_zkp::SECP256K1, OutPoint}; +use estimate_transaction_size::avg_vbytes; use futures::lock::Mutex; use rand::thread_rng; use wasm_bindgen::UnwrapThrowExt; @@ -18,8 +19,7 @@ pub async fn make_loan_request( current_wallet: &Mutex>, collateral_amount: Amount, fee_rate: Amount, - client: &EsploraClient, -) -> Result { +) -> Result { let btc_asset_id = { let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); *guard @@ -29,27 +29,87 @@ pub async fn make_loan_request( *guard }; - let mut wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - wallet.sync(&*client).await.map_err(Error::SyncWallet)?; + let (address, blinding_key) = { + let wallet = current(&name, current_wallet) + .await + .map_err(Error::LoadWallet)?; - let fee_offset = calculate_fee_offset(fee_rate); + let address = wallet.get_address(); + let blinding_key = wallet.blinding_key(); - let collateral_inputs = wallet - .coin_selection( - collateral_amount, - btc_asset_id, - fee_rate.as_sat() as f32, - fee_offset, - ) - .map_err(Error::CoinSelection)?; + (address, blinding_key) + }; + + let coin_selector = { + |amount, asset| async move { + let wallet = current(&name, current_wallet).await?; + + let utxos = get_txouts(&wallet, |utxo, txout| { + Ok({ + let unblinded_txout = txout.unblind(SECP256K1, blinding_key)?; + let outpoint = OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }; + let candidate_asset = unblinded_txout.asset; + + if candidate_asset == asset { + Some(( + coin_selection::Utxo { + outpoint, + value: unblinded_txout.value, + script_pubkey: txout.script_pubkey.clone(), + asset: candidate_asset, + }, + txout, + )) + } else { + log::debug!( + "utxo {} with asset id {} is not the target asset, ignoring", + outpoint, + candidate_asset + ); + None + } + }) + }) + .await?; + + let fee_offset = calculate_fee_offset(fee_rate); + + let output = coin_select( + utxos.iter().map(|(utxo, _)| utxo).cloned().collect(), + amount, + fee_rate.as_sat() as f32, + fee_offset, + )?; + let selection = output + .coins + .iter() + .map(|coin| { + let original_txout = utxos + .iter() + .find_map(|(utxo, txout)| (utxo.outpoint == coin.outpoint).then(|| txout)) + .expect("same source of utxos") + .clone(); + + Input { + txin: coin.outpoint, + original_txout, + blinding_key, + } + }) + .collect(); + + Ok(selection) + } + }; let borrower_state_0 = Borrower0::new( &mut thread_rng(), - collateral_inputs, - wallet.address(), - wallet.blinding_secret_key(), + coin_selector, + address, + blinding_key, collateral_amount, fee_rate, btc_asset_id, @@ -66,44 +126,9 @@ pub async fn make_loan_request( ) .map_err(Error::Save)?; - let loan_request = LoanRequestWalletParams::new( - *borrower_state_0.collateral_amount(), - borrower_state_0.collateral_inputs().to_vec(), - borrower_state_0.fee_sats_per_vbyte(), - borrower_state_0.pk(), - borrower_state_0.address().clone(), - ); - - Ok(loan_request) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct LoanRequestWalletParams { - #[serde(with = "::elements::bitcoin::util::amount::serde::as_sat")] - pub collateral_amount: Amount, - pub collateral_inputs: Vec, - #[serde(with = "::elements::bitcoin::util::amount::serde::as_sat")] - pub fee_sats_per_vbyte: Amount, - pub borrower_pk: PublicKey, - pub borrower_address: Address, -} - -impl LoanRequestWalletParams { - fn new( - collateral_amount: Amount, - collateral_inputs: Vec, - fee_sats_per_vbyte: Amount, - borrower_pk: PublicKey, - borrower_address: Address, - ) -> Self { - Self { - collateral_amount, - collateral_inputs, - fee_sats_per_vbyte, - borrower_pk, - borrower_address, - } - } + // TODO: Fix, use new API here + #[allow(deprecated)] + Ok(borrower_state_0.loan_request()) } #[derive(Debug, thiserror::Error)] @@ -118,10 +143,6 @@ pub enum Error { Save(anyhow::Error), #[error("Serialization failed: {0}")] Serialize(serde_json::Error), - #[error("Could not sync wallet: {0}")] - SyncWallet(anyhow::Error), - #[error("Coin selection: {0}")] - CoinSelection(anyhow::Error), } /// Calculate the fee offset required for the coin selection algorithm. diff --git a/extension/wallet/src/wallet/repay_loan.rs b/extension/wallet/src/wallet/repay_loan.rs index 987d791ae..d4c9e4877 100644 --- a/extension/wallet/src/wallet/repay_loan.rs +++ b/extension/wallet/src/wallet/repay_loan.rs @@ -1,15 +1,15 @@ -use baru::loan::Borrower1; +use baru::{input::Input, loan::Borrower1, swap::sign_with_key}; +use coin_selection::coin_select; use elements::{ - bitcoin::{secp256k1::SECP256K1, util::amount::Amount}, - Txid, + bitcoin::util::amount::Amount, secp256k1_zkp::SECP256K1, sighash::SigHashCache, OutPoint, Txid, }; use futures::lock::Mutex; use rand::thread_rng; use crate::{ - esplora::EsploraClient, + esplora::{broadcast, fetch_transaction}, storage::Storage, - wallet::{current, LoanDetails}, + wallet::{current, get_txouts, LoanDetails}, Wallet, DEFAULT_SAT_PER_VBYTE, }; @@ -20,11 +20,10 @@ pub async fn repay_loan( name: String, current_wallet: &Mutex>, loan_txid: Txid, - client: &EsploraClient, ) -> Result { // TODO: Only abort early if this fails because the transaction // hasn't been mined - if client.fetch_transaction(loan_txid).await.is_err() { + if fetch_transaction(loan_txid).await.is_err() { return Err(Error::NoLoan); } @@ -36,27 +35,125 @@ pub async fn repay_loan( .ok_or(Error::EmptyState)?; let borrower = serde_json::from_str::(&borrower).map_err(Error::Deserialize)?; - // We are selecting coins with an asset which cannot be - // used to pay for fees - let zero_fee_rate = 0f32; - let zero_fee_offset = Amount::ZERO; + let blinding_key = { + let wallet = current(&name, current_wallet) + .await + .map_err(Error::LoadWallet)?; + wallet.blinding_key() + }; + let coin_selector = { let name = name.clone(); |amount, asset| async move { - let mut wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - wallet.sync(client).await.map_err(Error::SyncWallet)?; - wallet.coin_selection(amount, asset, zero_fee_rate, zero_fee_offset) + let wallet = current(&name, current_wallet).await?; + + let utxos = get_txouts(&wallet, |utxo, txout| { + Ok({ + let unblinded_txout = txout.unblind(SECP256K1, blinding_key)?; + let outpoint = OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }; + let candidate_asset = unblinded_txout.asset; + + if candidate_asset == asset { + Some(( + coin_selection::Utxo { + outpoint, + value: unblinded_txout.value, + script_pubkey: txout.script_pubkey.clone(), + asset: candidate_asset, + }, + txout, + )) + } else { + log::debug!( + "utxo {} with asset id {} is not the target asset, ignoring", + outpoint, + candidate_asset + ); + None + } + }) + }) + .await?; + + // We are selecting coins with an asset which cannot be + // used to pay for fees + let zero_fee_rate = 0f32; + let zero_fee_offset = Amount::ZERO; + + let output = coin_select( + utxos.iter().map(|(utxo, _)| utxo).cloned().collect(), + amount, + zero_fee_rate, + zero_fee_offset, + )?; + let selection = output + .coins + .iter() + .map(|coin| { + let original_txout = utxos + .iter() + .find_map(|(utxo, txout)| (utxo.outpoint == coin.outpoint).then(|| txout)) + .expect("same source of utxos") + .clone(); + + Input { + txin: coin.outpoint, + original_txout, + blinding_key, + } + }) + .collect(); + + Ok(selection) } }; - let signer = |transaction| async { - let mut wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - wallet.sync(client).await.map_err(Error::SyncWallet)?; - Ok(wallet.sign(transaction)) + let signer = |mut transaction| async { + let wallet = current(&name, current_wallet).await?; + let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))).await?; + + let mut cache = SigHashCache::new(&transaction); + + let witnesses = transaction + .clone() + .input + .iter() + .enumerate() + .filter_map(|(index, input)| { + txouts + .iter() + .find(|(utxo, _)| { + utxo.txid == input.previous_output.txid + && utxo.vout == input.previous_output.vout + }) + .map(|(_, txout)| (index, txout)) + }) + .map(|(index, output)| { + // TODO: It is convenient to use this import, but + // it is weird to use an API from the swap library + // here. Maybe we should move it to a common + // place, so it can be used for different + // protocols + let script_witness = sign_with_key( + SECP256K1, + &mut cache, + index, + &wallet.secret_key, + output.value, + ); + + (index, script_witness) + }) + .collect::>(); + + for (index, witness) in witnesses { + transaction.input[index].witness.script_witness = witness + } + + Ok(transaction) }; let loan_repayment_tx = borrower @@ -70,8 +167,7 @@ pub async fn repay_loan( .await .map_err(Error::BuildTransaction)?; - let repayment_txid = client - .broadcast(loan_repayment_tx) + let repayment_txid = broadcast(loan_repayment_tx) .await .map_err(Error::SendTransaction)?; @@ -127,6 +223,4 @@ pub enum Error { BuildTransaction(anyhow::Error), #[error("Failed to broadcast transaction: {0}")] SendTransaction(anyhow::Error), - #[error("Could not sync wallet: {0}")] - SyncWallet(anyhow::Error), } diff --git a/extension/wallet/src/wallet/sign_and_send_swap_transaction.rs b/extension/wallet/src/wallet/sign_and_send_swap_transaction.rs index 22aaa5c94..d048dc7c7 100644 --- a/extension/wallet/src/wallet/sign_and_send_swap_transaction.rs +++ b/extension/wallet/src/wallet/sign_and_send_swap_transaction.rs @@ -1,30 +1,65 @@ use crate::{ - esplora::EsploraClient, - wallet::{current, Wallet}, + esplora::broadcast, + wallet::{current, get_txouts, Wallet}, }; use anyhow::Result; -use baru::swap::alice_finalize_transaction; -use elements::{Transaction, Txid}; +use baru::swap::{alice_finalize_transaction, sign_with_key}; +use elements::{secp256k1_zkp::SECP256K1, sighash::SigHashCache, Transaction, Txid}; use futures::lock::Mutex; pub(crate) async fn sign_and_send_swap_transaction( name: String, current_wallet: &Mutex>, transaction: Transaction, - client: &EsploraClient, ) -> Result { - let mut wallet = current(&name, current_wallet) + let wallet = current(&name, current_wallet) .await .map_err(Error::LoadWallet)?; - wallet.sync(&*client).await.map_err(Error::SyncWallet)?; - let transaction = alice_finalize_transaction(transaction, |transaction| async { - Ok(wallet.sign(transaction)) + let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))) + .await + .map_err(Error::GetTxOuts)?; + + let transaction = alice_finalize_transaction(transaction, |mut transaction| async { + let mut cache = SigHashCache::new(&transaction); + + let witnesses = transaction + .clone() + .input + .iter() + .enumerate() + .filter_map(|(index, input)| { + txouts + .iter() + .find(|(utxo, _)| { + utxo.txid == input.previous_output.txid + && utxo.vout == input.previous_output.vout + }) + .map(|(_, txout)| (index, txout)) + }) + .map(|(index, output)| { + let script_witness = sign_with_key( + SECP256K1, + &mut cache, + index, + &wallet.secret_key, + output.value, + ); + + (index, script_witness) + }) + .collect::>(); + + for (index, witness) in witnesses { + transaction.input[index].witness.script_witness = witness + } + + Ok(transaction) }) .await .map_err(Error::Sign)?; - let txid = client.broadcast(transaction).await.map_err(Error::Send)?; + let txid = broadcast(transaction).await.map_err(Error::Send)?; Ok(txid) } @@ -33,10 +68,10 @@ pub(crate) async fn sign_and_send_swap_transaction( pub enum Error { #[error("Wallet is not loaded: {0}")] LoadWallet(anyhow::Error), + #[error("Failed to get transaction outputs: {0}")] + GetTxOuts(anyhow::Error), #[error("Failed to sign transaction: {0}")] Sign(anyhow::Error), #[error("Failed to broadcast transaction: {0}")] Send(anyhow::Error), - #[error("Could not sync wallet: {0}")] - SyncWallet(anyhow::Error), } diff --git a/extension/wallet/src/wallet/sign_loan.rs b/extension/wallet/src/wallet/sign_loan.rs index adb101620..8e3eed51d 100644 --- a/extension/wallet/src/wallet/sign_loan.rs +++ b/extension/wallet/src/wallet/sign_loan.rs @@ -1,18 +1,16 @@ -use baru::loan::Borrower1; -use elements::Transaction; +use baru::{loan::Borrower1, swap::sign_with_key}; +use elements::{secp256k1_zkp::SECP256K1, sighash::SigHashCache, Transaction}; use futures::lock::Mutex; use crate::{ - esplora::EsploraClient, storage::Storage, - wallet::{current, LoanDetails}, + wallet::{current, get_txouts, LoanDetails}, Error, Wallet, }; pub(crate) async fn sign_loan( name: String, current_wallet: &Mutex>, - client: &EsploraClient, ) -> Result { let storage = Storage::local_storage().map_err(Error::Storage)?; // load temporary loan_borrower state. When the frontend _asks_ the extension to @@ -22,7 +20,7 @@ pub(crate) async fn sign_loan( // There can only be one pending loans at the time hence there is no identifier. let (borrower, loan_details) = load_borrower_state(&storage)?; - let loan_transaction = sign_transaction(&name, current_wallet, &borrower, client).await?; + let loan_transaction = sign_transaction(&name, current_wallet, &borrower).await?; // We don't broadcast this transaction ourselves, but we expect // the lender to do so very soon. We therefore save the borrower @@ -47,13 +45,48 @@ async fn sign_transaction( name: &str, current_wallet: &Mutex>, borrower: &Borrower1, - client: &EsploraClient, ) -> Result { let loan_transaction = borrower - .sign(|transaction| async { - let mut wallet = current(name, current_wallet).await?; - wallet.sync(client).await?; - let transaction = wallet.sign(transaction); + .sign(|mut transaction| async { + let wallet = current(name, current_wallet).await?; + let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))).await?; + + let mut cache = SigHashCache::new(&transaction); + let witnesses = transaction + .clone() + .input + .iter() + .enumerate() + .filter_map(|(index, input)| { + txouts + .iter() + .find(|(utxo, _)| { + utxo.txid == input.previous_output.txid + && utxo.vout == input.previous_output.vout + }) + .map(|(_, txout)| (index, txout)) + }) + .map(|(index, output)| { + // TODO: It is convenient to use this import, but + // it is weird to use an API from the swap library + // here. Maybe we should move it to a common + // place, so it can be used for different + // protocols + let script_witness = sign_with_key( + SECP256K1, + &mut cache, + index, + &wallet.secret_key, + output.value, + ); + + (index, script_witness) + }) + .collect::>(); + + for (index, witness) in witnesses { + transaction.input[index].witness.script_witness = witness + } Ok(transaction) }) diff --git a/extension/wallet/src/wallet/withdraw_everything_to.rs b/extension/wallet/src/wallet/withdraw_everything_to.rs index 937c01d43..fa2258765 100644 --- a/extension/wallet/src/wallet/withdraw_everything_to.rs +++ b/extension/wallet/src/wallet/withdraw_everything_to.rs @@ -1,10 +1,22 @@ use crate::{ - wallet::{current, Wallet, DEFAULT_SAT_PER_VBYTE}, - BTC_ASSET_ID, ESPLORA_CLIENT, + esplora, + wallet::{current, get_txouts, Wallet, DEFAULT_SAT_PER_VBYTE}, + BTC_ASSET_ID, }; -use anyhow::{Context, Result}; -use elements::{Address, Txid}; +use anyhow::{bail, Context, Result}; +use elements::{ + hashes::{hash160, Hash}, + opcodes, + script::Builder, + secp256k1_zkp::{rand, Message, SECP256K1}, + sighash::SigHashCache, + Address, OutPoint, SigHashType, Transaction, TxIn, TxOut, TxOutSecrets, Txid, +}; +use estimate_transaction_size::estimate_virtual_size; use futures::lock::Mutex; +use itertools::Itertools; +use rand::thread_rng; +use std::{collections::HashMap, iter}; use wasm_bindgen::UnwrapThrowExt; pub async fn withdraw_everything_to( @@ -12,31 +24,220 @@ pub async fn withdraw_everything_to( current_wallet: &Mutex>, address: Address, ) -> Result { - let client = ESPLORA_CLIENT.lock().expect_throw("can get lock"); - let btc_asset_id = { let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); *guard }; - let mut wallet = current(&name, current_wallet).await?; - wallet.sync(&*client).await?; + if !address.is_blinded() { + bail!("can only withdraw to blinded addresses") + } + + let wallet = current(&name, current_wallet).await?; + let blinding_key = wallet.blinding_key(); + + let txouts = get_txouts(&wallet, |utxo, txout| { + let unblinded_txout = txout.unblind(SECP256K1, blinding_key)?; + Ok(Some((utxo, txout, unblinded_txout))) + }) + .await?; + + let prevout_values = txouts + .iter() + .map(|(utxo, confidential, _)| { + ( + OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }, + confidential.value, + ) + }) + .collect::>(); + + let fee_estimates = esplora::get_fee_estimates().await?; + + let estimated_virtual_size = + estimate_virtual_size(prevout_values.len() as u64, txouts.len() as u64); + + let fee = (estimated_virtual_size as f32 + * fee_estimates.b_6.unwrap_or_else(|| { + let default_fee_rate = DEFAULT_SAT_PER_VBYTE; + log::info!( + "fee estimate for block target '6' unavailable, falling back to default fee {}", + default_fee_rate + ); + + default_fee_rate as f32 + })) as u64; // try to get into the next 6 blocks + + let txout_inputs = txouts + .iter() + .map(|(_, txout, secrets)| (txout.asset, secrets)) + .collect::>(); + + let txouts_grouped_by_asset = txouts + .iter() + .map(|(utxo, _, unblinded)| (unblinded.asset, (utxo, unblinded))) + .into_group_map() + .into_iter() + .map(|(asset, txouts)| { + // calculate the total amount we want to spend for this asset + // if this is the native asset, subtract the fee + let total_input = txouts.iter().map(|(_, txout)| txout.value).sum::(); + let to_spend = if asset == btc_asset_id { + log::debug!( + "{} is the native asset, subtracting a fee of {} from it", + asset, + fee + ); + + total_input - fee + } else { + total_input + }; + + log::debug!( + "found {} UTXOs for asset {} worth {} in total", + txouts.len(), + asset, + total_input + ); + + (asset, to_spend) + }) + .collect::>(); + + // build transaction from grouped txouts + let mut transaction = match txouts_grouped_by_asset.as_slice() { + [] => bail!("no balances in wallet"), + [(asset, _)] if *asset != btc_asset_id => { + bail!("cannot spend from wallet without native asset L-BTC because we cannot pay a fee",) + } + // handle last group separately because we need to create it is as the `last_confidential` output + [other @ .., (last_asset, to_spend_last_txout)] => { + // first, build all "non-last" outputs + let other_txouts = other + .iter() + .map(|(asset, to_spend)| { + let (txout, abf, vbf) = TxOut::new_not_last_confidential( + &mut thread_rng(), + SECP256K1, + *to_spend, + address.clone(), + *asset, + txout_inputs + .iter() + .map(|(asset, secrets)| (*asset, Some(*secrets))) + .collect::>() + .as_slice(), + )?; + + log::debug!( + "constructed non-last confidential output for asset {} with value {}", + asset, + to_spend + ); + + Ok((txout, asset, *to_spend, abf, vbf)) + }) + .collect::>>()?; + + // second, make the last one, depending on the previous ones + let last_txout = { + let other_outputs = other_txouts + .iter() + .map(|(_, asset, value, abf, vbf)| { + TxOutSecrets::new(**asset, *abf, *value, *vbf) + }) + .collect::>(); + + let (txout, _, _) = TxOut::new_last_confidential( + &mut thread_rng(), + SECP256K1, + *to_spend_last_txout, + address, + *last_asset, + txout_inputs.as_slice(), + other_outputs.iter().collect::>().as_ref(), + ) + .context("failed to make confidential txout")?; + + log::debug!( + "constructed last confidential output for asset {} with value {}", + last_asset, + to_spend_last_txout + ); + + txout + }; + + let txins = txouts + .into_iter() + .map(|(utxo, _, _)| TxIn { + previous_output: OutPoint { + txid: utxo.txid, + vout: utxo.vout, + }, + is_pegin: false, + has_issuance: false, + script_sig: Default::default(), + sequence: 0, + asset_issuance: Default::default(), + witness: Default::default(), + }) + .collect::>(); + let txouts = other_txouts + .iter() + .map(|(txout, _, _, _, _)| txout) + .chain(iter::once(&last_txout)) + .chain(iter::once(&TxOut::new_fee(fee, btc_asset_id))) + .cloned() + .collect::>(); + + Transaction { + version: 2, + lock_time: 0, + input: txins, + output: txouts, + } + } + }; + + let tx_clone = transaction.clone(); + let mut cache = SigHashCache::new(&tx_clone); + + for (index, input) in transaction.input.iter_mut().enumerate() { + input.witness.script_witness = { + let hash = hash160::Hash::hash(&wallet.get_public_key().serialize()); + let script = Builder::new() + .push_opcode(opcodes::all::OP_DUP) + .push_opcode(opcodes::all::OP_HASH160) + .push_slice(&hash.into_inner()) + .push_opcode(opcodes::all::OP_EQUALVERIFY) + .push_opcode(opcodes::all::OP_CHECKSIG) + .into_script(); + + let sighash = cache.segwitv0_sighash( + index, + &script, + prevout_values[&input.previous_output], + SigHashType::All, + ); - let fee_estimates = client.get_fee_estimates().await?; - let fee_rate = fee_estimates.b_6.unwrap_or_else(|| { - let default_fee_rate = DEFAULT_SAT_PER_VBYTE; - log::info!( - "fee estimate for block target '6' unavailable, falling back to default fee {}", - default_fee_rate - ); + let sig = SECP256K1.sign(&Message::from(sighash), &wallet.secret_key); - default_fee_rate as f32 - }); // try to get into the next 6 blocks; + let mut serialized_signature = sig.serialize_der().to_vec(); + serialized_signature.push(SigHashType::All as u8); - let transaction = wallet.withdraw_everything_to_transaction(address, btc_asset_id, fee_rate)?; + vec![ + serialized_signature, + wallet.get_public_key().serialize().to_vec(), + ] + } + } - let txid = client - .broadcast(transaction) + let txid = esplora::broadcast(transaction) .await .context("failed to broadcast transaction via esplora")?;