diff --git a/Cargo.lock b/Cargo.lock index 0ca9d6108..9f59776c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "aead" version = "0.3.2" @@ -2383,6 +2385,7 @@ version = "0.1.0" dependencies = [ "aes-gcm-siv", "anyhow", + "async-trait", "baru", "bdk", "coin_selection", diff --git a/extension/wallet/Cargo.toml b/extension/wallet/Cargo.toml index 79bc3ff6b..510dd15ad 100644 --- a/extension/wallet/Cargo.toml +++ b/extension/wallet/Cargo.toml @@ -13,6 +13,7 @@ default = [ "console_error_panic_hook" ] [dependencies] aes-gcm-siv = { version = "0.9", features = [ "std" ] } anyhow = "1" +async-trait = "0.1" baru = "0.1" bdk = { version = "0.4", default-features = false } coin_selection = { path = "../../coin_selection" } diff --git a/extension/wallet/src/cache_storage.rs b/extension/wallet/src/cache_storage.rs deleted file mode 100644 index 9048c3665..000000000 --- a/extension/wallet/src/cache_storage.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::storage::Storage; -use anyhow::{Context, Result}; - -/// A wrapper type around the local storage acting as cache for http requests. -pub struct CacheStorage { - inner: Storage, -} - -impl CacheStorage { - pub fn new() -> Result { - let local_storage = Storage::local_storage().with_context(|| "Could not open storage")?; - Ok(Self { - inner: local_storage, - }) - } - - /// This function will fetch the provided URL and store the response body in local storage. - /// It will fail if the response body is not a string. - async fn add(&self, url: &str) -> Result<()> { - let client = reqwest::Client::new(); - let body = client.get(url).send().await?; - let body_text = body - .text() - .await - .with_context(|| "response is not a string")?; - self.inner - .set_item(url, &body_text) - .with_context(|| format!("failed to add request for {} to storage", url))?; - - Ok(()) - } - - async fn match_with_str(&self, url: &str) -> Result> { - let maybe_response = self.inner.get_item(url)?; - match maybe_response { - None => Ok(None), - Some(response) => Ok(Some(Response { inner: response })), - } - } - - /// Convenience function that first tries to look up the value in the storage and if it is not present adds and returns it. - /// - /// This function will always return a response IF the request was successful (2xx status code). - /// Failed requests will never be added to the storage. - pub async fn match_or_add(&self, url: &str) -> Result { - Ok(match self.match_with_str(url).await? { - Some(response) => response, - None => { - self.add(url).await?; - self.match_with_str(url) - .await? - .context("no response in storage")? - } - }) - } -} - -pub struct Response { - inner: String, -} - -impl Response { - pub async fn text(&self) -> Result { - Ok(self.inner.clone()) - } -} diff --git a/extension/wallet/src/esplora.rs b/extension/wallet/src/esplora.rs deleted file mode 100644 index 4f41d496a..000000000 --- a/extension/wallet/src/esplora.rs +++ /dev/null @@ -1,285 +0,0 @@ -use crate::{cache_storage::CacheStorage, ESPLORA_API_URL}; -use anyhow::{anyhow, bail, Context, Result}; -use elements::{ - encode::{deserialize, serialize_hex}, - Address, BlockHash, Transaction, Txid, -}; -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. -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 = esplora_url.join(path.as_str())?; - let response = reqwest::get(esplora_url.clone()) - .await - .context("failed to fetch UTXOs")?; - - if response.status() == StatusCode::NOT_FOUND { - log::debug!( - "GET {} returned 404, defaulting to empty UTXO set", - esplora_url - ); - - return Ok(Vec::new()); - } - - if !response.status().is_success() { - let error_body = response.text().await?; - return Err(anyhow!( - "failed to fetch utxos, esplora returned '{}'", - error_body - )); - } - - response - .json::>() - .await - .context("failed to deserialize response") -} - -/// 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(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", esplora_url, txid)) - .await? - .text() - .await?; - - 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). -/// The available confirmation targets are 1-25, 144, 504 and 1008 blocks. -#[derive(serde::Deserialize, Debug)] -pub struct FeeEstimatesResponse { - #[serde(rename = "1")] - pub b_1: Option, - #[serde(rename = "2")] - pub b_2: Option, - #[serde(rename = "3")] - pub b_3: Option, - #[serde(rename = "4")] - pub b_4: Option, - #[serde(rename = "5")] - pub b_5: Option, - #[serde(rename = "6")] - pub b_6: Option, - #[serde(rename = "7")] - pub b_7: Option, - #[serde(rename = "8")] - pub b_8: Option, - #[serde(rename = "9")] - pub b_9: Option, - #[serde(rename = "10")] - pub b_10: Option, - #[serde(rename = "11")] - pub b_11: Option, - #[serde(rename = "12")] - pub b_12: Option, - #[serde(rename = "13")] - pub b_13: Option, - #[serde(rename = "14")] - pub b_14: Option, - #[serde(rename = "15")] - pub b_15: Option, - #[serde(rename = "16")] - pub b_16: Option, - #[serde(rename = "17")] - pub b_17: Option, - #[serde(rename = "18")] - pub b_18: Option, - #[serde(rename = "19")] - pub b_19: Option, - #[serde(rename = "20")] - pub b_20: Option, - #[serde(rename = "21")] - pub b_21: Option, - #[serde(rename = "22")] - pub b_22: Option, - #[serde(rename = "23")] - pub b_23: Option, - #[serde(rename = "24")] - pub b_24: Option, - #[serde(rename = "25")] - pub b_25: Option, - #[serde(rename = "144")] - pub b_144: Option, - #[serde(rename = "504")] - pub b_504: Option, - #[serde(rename = "1008")] - pub b_1008: Option, -} - -/// Represents a UTXO as it is modeled by esplora. -/// -/// We ignore the commitments and asset IDs because we need to fetch the full transaction anyway. -/// Hence, we don't even bother with deserializing it here. -#[derive(serde::Deserialize, Debug, PartialEq, Clone, Copy)] -pub struct Utxo { - pub txid: Txid, - pub vout: u32, - pub status: UtxoStatus, -} - -#[derive(serde::Deserialize, Debug, PartialEq, Clone, Copy)] -pub struct UtxoStatus { - pub confirmed: bool, - pub block_height: Option, - pub block_hash: Option, - pub block_time: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn can_deserialize_confidential_utxo() { - let utxos = r#"[ - { - "txid": "26ad78aca6db29fa6ca37337fcfb23498dc1a01ee274614970097ab7ca6b6a19", - "vout": 0, - "status": { - "confirmed": true, - "block_height": 1099688, - "block_hash": "e0dd686b1a3334e941512a0e08dda69c9db71cd642d8b219f6063fb81838d86b", - "block_time": 1607042939 - }, - "valuecommitment": "0959edffa4326a255a15925a5a8eeda37e27fb80a62b1f1792dcd98bb8e29b7496", - "assetcommitment": "0b7b0f23047a44d6145fb4754f218807c1a3f0acc811221f7ba35e44dfc3a31795", - "noncecommitment": "039b1feace0413efc144298bc462a90bbf8f269cf68e3dfa65088f84f381921261" - } -] -"#; - - let utxos = serde_json::from_str::>(utxos).unwrap(); - - assert_eq!(utxos.len(), 1); - } - - #[test] - fn can_deserialize_explicit_utxo() { - let utxos = r#"[ - { - "txid": "58035633e6391fd08955f9f73b710efe3835a7975baaf1267aa4fcb3c738c1ba", - "vout": 0, - "status": { - "confirmed": true, - "block_height": 1099644, - "block_hash": "58d573591f8920b225512bb209b5d75f2ae9260f107c306b87a53c4cc4d42d7e", - "block_time": 1607040059 - }, - "value": 99958, - "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d" - } -] -"#; - - let utxos = serde_json::from_str::>(utxos).unwrap(); - - assert_eq!(utxos.len(), 1); - } -} diff --git a/extension/wallet/src/lib.rs b/extension/wallet/src/lib.rs index a6384b390..ee03e0e87 100644 --- a/extension/wallet/src/lib.rs +++ b/extension/wallet/src/lib.rs @@ -1,32 +1,32 @@ -use std::str::FromStr; - use conquer_once::Lazy; use elements::{ bitcoin::util::amount::{Amount, Denomination}, - Address, AddressParams, Txid, + Address, AddressParams, AssetId, Txid, }; use futures::lock::Mutex; use js_sys::Promise; +use reqwest::Url; +use rust_decimal::Decimal; +use std::str::FromStr; use wasm_bindgen::{prelude::*, JsCast}; use web_sys::window; +use crate::{ + lib_wallet::{LoanDetails, Wallet}, + storage::Storage, + wallet::*, +}; +use baru::loan::Borrower1; + #[macro_use] mod macros; -mod assets; -mod cache_storage; -mod esplora; +mod lib_wallet; mod logger; mod storage; mod wallet; -use crate::{storage::Storage, wallet::*}; -use reqwest::Url; - -// TODO: make this configurable through extension option UI -const DEFAULT_SAT_PER_VBYTE: u64 = 1; - -static LOADED_WALLET: Lazy>> = Lazy::new(Mutex::default); +static LOADED_WALLET: Lazy>> = Lazy::new(Mutex::default); // TODO: I was unable to use `futures::lock::Mutex` for these, but // someone else should be able to do it @@ -91,7 +91,7 @@ pub fn setup() { /// The created wallet will be automatically loaded. #[wasm_bindgen] pub async fn create_new_wallet(name: String, password: String) -> Result { - map_err_from_anyhow!(wallet::create_new(name, password, &LOADED_WALLET).await)?; + map_err_from_anyhow!(wallet::create_new_waves(name, password, &LOADED_WALLET).await)?; Ok(JsValue::null()) } @@ -131,12 +131,36 @@ pub async fn wallet_status(name: String) -> Result { /// Fails if the wallet is currently not loaded. #[wasm_bindgen] pub async fn get_address(name: String) -> Result { - let address = map_err_from_anyhow!(wallet::get_address(name, &LOADED_WALLET).await)?; + let wallet = current(&name, &LOADED_WALLET).await.unwrap(); + let address = wallet.get_address(); let address = map_err_from_anyhow!(JsValue::from_serde(&address))?; Ok(address) } +/// 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, + } + } +} + /// Get the balances of the currently loaded wallet. /// /// Returns an array of [`BalanceEntry`]s. @@ -144,7 +168,20 @@ 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 balance_entries = map_err_from_anyhow!(wallet::get_balances(&name, &LOADED_WALLET).await)?; + let wallet = current(&name, &LOADED_WALLET).await.unwrap(); + let balance_entries = map_err_from_anyhow!(crate::lib_wallet::get_balances(&*wallet).await)?; + let balance_entries = balance_entries + .iter() + .map(|entry| { + let (ticker, precision) = lookup(entry.asset).unwrap(); + BalanceEntry::for_asset( + entry.asset, + ticker.to_string(), + entry.value, + precision as u32, + ) + }) + .collect::>(); let balance_entries = map_err_from_anyhow!(JsValue::from_serde(&balance_entries))?; Ok(balance_entries) @@ -155,9 +192,15 @@ pub async fn get_balances(name: String) -> Result { /// Returns the transaction ID of the transaction that was broadcasted. #[wasm_bindgen] pub async fn withdraw_everything_to(name: String, address: String) -> Result { + let wallet = current(&name, &LOADED_WALLET).await.unwrap(); let address = map_err_from_anyhow!(address.parse::
())?; - let txid = - map_err_from_anyhow!(wallet::withdraw_everything_to(name, &LOADED_WALLET, address).await)?; + let btc_asset_id = { + let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let txid = map_err_from_anyhow!( + crate::lib_wallet::withdraw_everything_to(&*wallet, address, btc_asset_id).await + )?; let txid = map_err_from_anyhow!(JsValue::from_serde(&txid))?; Ok(txid) @@ -172,8 +215,23 @@ pub async fn make_buy_create_swap_payload( usdt: String, ) -> Result { let usdt = map_err_from_anyhow!(Amount::from_str_in(&usdt, Denomination::Bitcoin))?; + let btc_asset_id = { + let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let usdt_asset_id = { + let guard = USDT_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); let payload = map_err_from_anyhow!( - wallet::make_buy_create_swap_payload(wallet_name, &LOADED_WALLET, usdt).await + crate::lib_wallet::make_buy_create_swap_payload( + &*wallet, + usdt, + btc_asset_id, + usdt_asset_id + ) + .await )?; let payload = map_err_from_anyhow!(JsValue::from_serde(&payload))?; @@ -189,8 +247,13 @@ pub async fn make_sell_create_swap_payload( btc: String, ) -> Result { let btc = map_err_from_anyhow!(Amount::from_str_in(&btc, Denomination::Bitcoin))?; + let btc_asset_id = { + let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); let payload = map_err_from_anyhow!( - wallet::make_sell_create_swap_payload(wallet_name, &LOADED_WALLET, btc).await + crate::lib_wallet::make_sell_create_swap_payload(&*wallet, btc, btc_asset_id).await )?; let payload = map_err_from_anyhow!(JsValue::from_serde(&payload))?; @@ -210,9 +273,26 @@ pub async fn make_loan_request( collateral: String, ) -> Result { let collateral = map_err_from_anyhow!(Amount::from_str_in(&collateral, Denomination::Bitcoin))?; - let loan_request = map_err_from_anyhow!( - wallet::make_loan_request(wallet_name, &LOADED_WALLET, collateral).await + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); + let btc_asset_id = { + let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let usdt_asset_id = { + let guard = USDT_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let (borrower, loan_request) = map_err_from_anyhow!( + crate::lib_wallet::make_loan_request(&*wallet, collateral, btc_asset_id, usdt_asset_id) + .await )?; + + let storage = map_err_from_anyhow!(Storage::local_storage())?; + map_err_from_anyhow!(storage.set_item( + "borrower_state", + map_err_from_anyhow!(serde_json::to_string(&borrower))?, + ))?; + let loan_request = map_err_from_anyhow!(JsValue::from_serde(&loan_request))?; Ok(loan_request) @@ -224,7 +304,38 @@ pub async fn make_loan_request( /// Returns the signed transaction. #[wasm_bindgen] pub async fn sign_loan(wallet_name: String) -> Result { - let loan_tx = map_err_from_anyhow!(wallet::sign_loan(wallet_name, &LOADED_WALLET).await)?; + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); + let storage = Storage::local_storage().unwrap(); + let borrower = storage + .get_item::("borrower_state") + .unwrap() + .unwrap(); + let (borrower1, loan_details) = + serde_json::from_str::<(Borrower1, LoanDetails)>(&borrower).unwrap(); + let loan_tx = map_err_from_anyhow!(crate::lib_wallet::sign_loan(&*wallet, borrower1).await)?; + + // We don't broadcast this transaction ourselves, but we expect + // the lender to do so very soon. We therefore save the borrower + // state so that we can later on build, sign and broadcast the + // repayment transaction + + let mut open_loans = match storage.get_item::("open_loans").unwrap() { + Some(open_loans) => serde_json::from_str(&open_loans).unwrap(), + None => Vec::::new(), + }; + + open_loans.push(loan_details); + storage + .set_item("open_loans", serde_json::to_string(&open_loans).unwrap()) + .unwrap(); + + storage + .set_item( + &format!("loan_state:{}", loan_tx.txid()), + serde_json::to_string(&borrower).unwrap(), + ) + .unwrap(); + let loan_tx = map_err_from_anyhow!(JsValue::from_serde(&Transaction::from(loan_tx)))?; Ok(loan_tx) @@ -239,9 +350,9 @@ pub async fn sign_and_send_swap_transaction( transaction: JsValue, ) -> Result { let transaction: Transaction = map_err_from_anyhow!(transaction.into_serde())?; + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); let txid = map_err_from_anyhow!( - wallet::sign_and_send_swap_transaction(wallet_name, &LOADED_WALLET, transaction.into()) - .await + crate::lib_wallet::sign_and_send_swap_transaction(&*wallet, transaction.into()).await )?; let txid = map_err_from_anyhow!(JsValue::from_serde(&txid))?; @@ -257,8 +368,9 @@ 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 wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); let trade = map_err_from_anyhow!( - wallet::extract_trade(wallet_name, &LOADED_WALLET, transaction.into()).await + crate::lib_wallet::extract_trade(&*wallet, transaction.into(),).await )?; let trade = map_err_from_anyhow!(JsValue::from_serde(&trade))?; @@ -280,43 +392,101 @@ 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 details = map_err_from_anyhow!( - wallet::extract_loan(wallet_name, &LOADED_WALLET, loan_response).await - )?; - let details = map_err_from_anyhow!(JsValue::from_serde(&details))?; + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); - Ok(details) -} + let btc_asset_id = { + let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; + let usdt_asset_id = { + let guard = USDT_ASSET_ID.lock().expect_throw("can get lock"); + *guard + }; -/// Returns all the active loans stored in the browser's local storage. -#[wasm_bindgen] -pub async fn get_open_loans() -> Result { - let storage = map_err_from_anyhow!(Storage::local_storage())?; - let loans = map_err_from_anyhow!(storage.get_open_loans().await)?; - let loans = map_err_from_anyhow!(JsValue::from_serde(&loans))?; + let storage = Storage::local_storage().unwrap(); + let borrower0 = storage + .get_item::("borrower_state") + .unwrap() + .unwrap(); + let borrower0 = serde_json::from_str(&borrower0).unwrap(); + + let (borrower1, loan_details) = map_err_from_anyhow!( + crate::lib_wallet::extract_loan( + &*wallet, + loan_response, + btc_asset_id, + usdt_asset_id, + borrower0 + ) + .await + )?; + storage + .set_item( + "borrower_state", + serde_json::to_string(&(borrower1, loan_details.clone())).unwrap(), + ) + .unwrap(); + let details = map_err_from_anyhow!(JsValue::from_serde(&loan_details))?; - Ok(loans) + Ok(details) } #[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 storage = Storage::local_storage().unwrap(); + + let borrower1 = storage + .get_item::(&format!("loan_state:{}", loan_txid)) + .unwrap() + .unwrap(); + let borrower1 = serde_json::from_str(&borrower1).unwrap(); + + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); let txid = - map_err_from_anyhow!(wallet::repay_loan(wallet_name, &LOADED_WALLET, loan_txid).await)?; + map_err_from_anyhow!(crate::lib_wallet::repay_loan(&*wallet, loan_txid, borrower1).await)?; let txid = map_err_from_anyhow!(JsValue::from_serde(&txid))?; + // TODO: Make sure that we can safely forget this i.e. sufficient confirmations + storage + .remove_item(&format!("loan_state:{}", loan_txid)) + .unwrap(); + + let open_loans = match storage.get_item::("open_loans").unwrap() { + Some(open_loans) => serde_json::from_str(&open_loans).unwrap(), + None => Vec::::new(), + }; + let open_loans = open_loans + .iter() + .take_while(|details| loan_txid != details.txid) + .collect::>(); + storage + .set_item("open_loans", serde_json::to_string(&open_loans).unwrap()) + .unwrap(); + Ok(txid) } #[wasm_bindgen] pub async fn get_past_transactions(wallet_name: String) -> Result { - let history = - map_err_from_anyhow!(wallet::get_transaction_history(wallet_name, &LOADED_WALLET).await)?; + let wallet = current(&wallet_name, &LOADED_WALLET).await.unwrap(); + let history = map_err_from_anyhow!(crate::lib_wallet::get_transaction_history(&*wallet).await)?; let history = map_err_from_anyhow!(JsValue::from_serde(&history))?; Ok(history) } +/// Returns all the active loans stored in the browser's local storage. +#[wasm_bindgen] +pub async fn get_open_loans() -> Result { + let storage = map_err_from_anyhow!(Storage::local_storage())?; + let loans = map_err_from_anyhow!(storage.get_open_loans().await)?; + let loans = map_err_from_anyhow!(JsValue::from_serde(&loans))?; + + Ok(loans) +} + fn handle_storage_update(event: web_sys::StorageEvent) -> Promise { match (event.key().as_deref(), event.new_value().as_deref()) { (Some("CHAIN"), Some(new_value)) => { diff --git a/extension/wallet/src/lib_wallet.rs b/extension/wallet/src/lib_wallet.rs new file mode 100644 index 000000000..4f0d7a651 --- /dev/null +++ b/extension/wallet/src/lib_wallet.rs @@ -0,0 +1,348 @@ +use std::ops::{Add, Sub}; + +use anyhow::Result; +use async_trait::async_trait; +use bdk::bitcoin::secp256k1::PublicKey; +use elements::{ + bitcoin::{secp256k1::SECP256K1, Amount}, + confidential, + secp256k1_zkp::SecretKey, + Address, AssetId, BlockHash, OutPoint, Transaction, TxOut, Txid, +}; +use futures::{stream::FuturesUnordered, StreamExt, TryStreamExt}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +pub use crate::lib_wallet::{ + extract_loan::{extract_loan, Error as ExtractLoanError}, + extract_trade::extract_trade, + get_balances::get_balances, + get_transaction_history::get_transaction_history, + make_create_swap_payload::{ + make_buy_create_swap_payload, make_sell_create_swap_payload, Error as MakePayloadError, + }, + make_loan_request::{make_loan_request, Error as MakeLoanRequestError}, + repay_loan::repay_loan, + sign_and_send_swap_transaction::sign_and_send_swap_transaction, + sign_loan::sign_loan, + withdraw_everything_to::withdraw_everything_to, +}; + +mod extract_loan; +mod extract_trade; +mod get_balances; +mod get_transaction_history; +mod make_create_swap_payload; +mod make_loan_request; +mod repay_loan; +mod sign_and_send_swap_transaction; +mod sign_loan; +mod withdraw_everything_to; + +// TODO: make this configurable through extension option UI +pub const DEFAULT_SAT_PER_VBYTE: u64 = 1; + +#[async_trait] +pub trait Wallet { + async fn fetch_transaction(&self, txid: Txid) -> Result; + async fn fetch_transaction_history(&self, address: &Address) -> Result>; + async fn get_fee_estimates(&self) -> Result; + async fn broadcast(&self, tx: Transaction) -> Result; + fn get_address(&self) -> Address; + fn blinding_key(&self) -> SecretKey; + fn secret_key(&self) -> SecretKey; + fn public_key(&self) -> PublicKey { + PublicKey::from_secret_key(SECP256K1, &self.secret_key()) + } + /// A pure function to compute the balances of the wallet given a set of [`TxOut`]s. + fn compute_balances(&self, 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, self.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() + .map(|(asset, utxos)| { + let total_sum = utxos.into_iter().sum(); + BalanceEntry { + asset, + value: total_sum, + } + }) + .collect() + } + async fn fetch_utxos(&self) -> Result>; + async fn get_txouts Result> + Copy + Send>( + &self, + filter_map: FM, + ) -> Result> { + let utxos = self.fetch_utxos().await?; + + let txouts = utxos + .into_iter() + .map(move |utxo| async move { + let mut tx = self.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) + } +} + +/// 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). +/// The available confirmation targets are 1-25, 144, 504 and 1008 blocks. +#[derive(serde::Deserialize, Debug)] +pub struct FeeEstimatesResponse { + #[serde(rename = "1")] + pub b_1: Option, + #[serde(rename = "2")] + pub b_2: Option, + #[serde(rename = "3")] + pub b_3: Option, + #[serde(rename = "4")] + pub b_4: Option, + #[serde(rename = "5")] + pub b_5: Option, + #[serde(rename = "6")] + pub b_6: Option, + #[serde(rename = "7")] + pub b_7: Option, + #[serde(rename = "8")] + pub b_8: Option, + #[serde(rename = "9")] + pub b_9: Option, + #[serde(rename = "10")] + pub b_10: Option, + #[serde(rename = "11")] + pub b_11: Option, + #[serde(rename = "12")] + pub b_12: Option, + #[serde(rename = "13")] + pub b_13: Option, + #[serde(rename = "14")] + pub b_14: Option, + #[serde(rename = "15")] + pub b_15: Option, + #[serde(rename = "16")] + pub b_16: Option, + #[serde(rename = "17")] + pub b_17: Option, + #[serde(rename = "18")] + pub b_18: Option, + #[serde(rename = "19")] + pub b_19: Option, + #[serde(rename = "20")] + pub b_20: Option, + #[serde(rename = "21")] + pub b_21: Option, + #[serde(rename = "22")] + pub b_22: Option, + #[serde(rename = "23")] + pub b_23: Option, + #[serde(rename = "24")] + pub b_24: Option, + #[serde(rename = "25")] + pub b_25: Option, + #[serde(rename = "144")] + pub b_144: Option, + #[serde(rename = "504")] + pub b_504: Option, + #[serde(rename = "1008")] + pub b_1008: Option, +} + +/// A single balance entry as returned by [`get_balances`]. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub struct BalanceEntry { + pub asset: AssetId, + pub value: u64, +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LoanDetails { + pub collateral: TradeSide, + pub principal: TradeSide, + pub principal_repayment: u64, + // TODO: Express as target date or number of days instead? + pub term: u64, + pub txid: Txid, +} + +impl LoanDetails { + #[allow(clippy::too_many_arguments)] + pub fn new( + collateral_asset: AssetId, + collateral_amount: Amount, + collateral_balance: u64, + principal_asset: AssetId, + principal_amount: Amount, + principal_balance: u64, + timelock: u64, + txid: Txid, + ) -> Result { + let collateral = TradeSide::new_sell( + collateral_asset, + collateral_amount.as_sat(), + collateral_balance, + )?; + + let principal = TradeSide::new_buy( + principal_asset, + principal_amount.as_sat(), + principal_balance, + )?; + + Ok(Self { + collateral, + principal_repayment: principal.amount, + principal, + term: timelock, + txid, + }) + } +} + +/// Represents a UTXO as it is modeled by esplora. +/// +/// We ignore the commitments and asset IDs because we need to fetch the full transaction anyway. +/// Hence, we don't even bother with deserializing it here. +// todo!(Make our own UTXO type, this is a http payload) +#[derive(serde::Deserialize, Debug, PartialEq, Clone, Copy)] +pub struct Utxo { + pub txid: Txid, + pub vout: u32, + pub status: UtxoStatus, +} + +#[derive(serde::Deserialize, Debug, PartialEq, Clone, Copy)] +pub struct UtxoStatus { + pub confirmed: bool, + pub block_height: Option, + pub block_hash: Option, + pub block_time: Option, +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TradeSide { + pub asset: AssetId, + pub amount: u64, + pub balance_before: u64, + pub balance_after: u64, +} + +impl TradeSide { + fn new_sell(asset: AssetId, amount: u64, current_balance: u64) -> Result { + Self::new(asset, amount, current_balance, u64::sub) + } + + fn new_buy(asset: AssetId, amount: u64, current_balance: u64) -> Result { + Self::new(asset, amount, current_balance, u64::add) + } + + fn new( + asset: AssetId, + amount: u64, + current_balance: u64, + balance_after: impl Fn(u64, u64) -> u64, + ) -> Result { + Ok(Self { + asset, + amount, + balance_before: current_balance, + balance_after: balance_after(current_balance, amount), + }) + } +} + +// todo!(Make our own UTXO type, this is a http payload) +/// Represents the payload for creating a swap. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct CreateSwapPayload { + pub alice_inputs: Vec, + pub address: Address, + #[serde(with = "bdk::bitcoin::util::amount::serde::as_sat")] + pub amount: bdk::bitcoin::Amount, +} + +// todo!(Make our own UTXO type, this is a http payload) +#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] +pub struct SwapUtxo { + pub outpoint: OutPoint, + pub blinding_key: SecretKey, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_deserialize_confidential_utxo() { + let utxos = r#"[ + { + "txid": "26ad78aca6db29fa6ca37337fcfb23498dc1a01ee274614970097ab7ca6b6a19", + "vout": 0, + "status": { + "confirmed": true, + "block_height": 1099688, + "block_hash": "e0dd686b1a3334e941512a0e08dda69c9db71cd642d8b219f6063fb81838d86b", + "block_time": 1607042939 + }, + "valuecommitment": "0959edffa4326a255a15925a5a8eeda37e27fb80a62b1f1792dcd98bb8e29b7496", + "assetcommitment": "0b7b0f23047a44d6145fb4754f218807c1a3f0acc811221f7ba35e44dfc3a31795", + "noncecommitment": "039b1feace0413efc144298bc462a90bbf8f269cf68e3dfa65088f84f381921261" + } +] +"#; + + let utxos = serde_json::from_str::>(utxos).unwrap(); + + assert_eq!(utxos.len(), 1); + } + + #[test] + fn can_deserialize_explicit_utxo() { + let utxos = r#"[ + { + "txid": "58035633e6391fd08955f9f73b710efe3835a7975baaf1267aa4fcb3c738c1ba", + "vout": 0, + "status": { + "confirmed": true, + "block_height": 1099644, + "block_hash": "58d573591f8920b225512bb209b5d75f2ae9260f107c306b87a53c4cc4d42d7e", + "block_time": 1607040059 + }, + "value": 99958, + "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d" + } +] +"#; + + let utxos = serde_json::from_str::>(utxos).unwrap(); + + assert_eq!(utxos.len(), 1); + } +} diff --git a/extension/wallet/src/lib_wallet/extract_loan.rs b/extension/wallet/src/lib_wallet/extract_loan.rs new file mode 100644 index 000000000..1c556129b --- /dev/null +++ b/extension/wallet/src/lib_wallet/extract_loan.rs @@ -0,0 +1,79 @@ +use crate::lib_wallet::{LoanDetails, Wallet}; +use baru::loan::{Borrower0, Borrower1, LoanResponse}; +use elements::{secp256k1_zkp::SECP256K1, AssetId}; + +pub async fn extract_loan( + wallet: &W, + loan_response: LoanResponse, + btc_asset_id: AssetId, + usdt_asset_id: AssetId, + borrower0: Borrower0, +) -> Result<(Borrower1, LoanDetails), Error> { + let txouts = wallet + .get_txouts(|utxo, txout| Ok(Some((utxo, txout)))) + .await + .map_err(Error::GetTxOuts)?; + let balances = wallet.compute_balances( + &txouts + .iter() + .map(|(_, txout)| txout) + .cloned() + .collect::>(), + ); + + let timelock = loan_response.timelock; + let borrower1 = borrower0 + .interpret(SECP256K1, loan_response) + .map_err(Error::InterpretLoanResponse)?; + + let collateral_balance = balances + .iter() + .find_map(|entry| { + if entry.asset == btc_asset_id { + Some(entry.value) + } else { + None + } + }) + .ok_or(Error::InsufficientCollateral)?; + + let principal_balance = balances + .iter() + .find_map(|entry| { + if entry.asset == usdt_asset_id { + Some(entry.value) + } else { + None + } + }) + .unwrap_or_default(); + + let loan_txid = borrower1.loan_transaction.txid(); + let loan_details = LoanDetails::new( + btc_asset_id, + borrower1.collateral_amount, + collateral_balance, + usdt_asset_id, + borrower1.principal_tx_out_amount, + principal_balance, + timelock, + loan_txid, + ) + .map_err(Error::LoanDetails)?; + + Ok((borrower1, loan_details)) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to deserialise loan response: {0}")] + LoanResponseDeserialization(#[from] serde_json::Error), + #[error("Failed to get transaction outputs: {0}")] + GetTxOuts(anyhow::Error), + #[error("Failed to interpret loan response: {0}")] + InterpretLoanResponse(anyhow::Error), + #[error("Not enough collateral to put up for loan")] + InsufficientCollateral, + #[error("Failed to build loan details: {0}")] + LoanDetails(anyhow::Error), +} diff --git a/extension/wallet/src/wallet/extract_trade.rs b/extension/wallet/src/lib_wallet/extract_trade.rs similarity index 91% rename from extension/wallet/src/wallet/extract_trade.rs rename to extension/wallet/src/lib_wallet/extract_trade.rs index 8d19ceec6..104f56c3b 100644 --- a/extension/wallet/src/wallet/extract_trade.rs +++ b/extension/wallet/src/lib_wallet/extract_trade.rs @@ -1,24 +1,18 @@ -use crate::{ - wallet::{compute_balances, current, get_txouts, Wallet}, - TradeSide, -}; +use crate::lib_wallet::{TradeSide, Wallet}; use anyhow::{bail, Context, Result}; use elements::{confidential, secp256k1_zkp::SECP256K1, Transaction, TxOut}; -use futures::lock::Mutex; use itertools::Itertools; use serde::{Deserialize, Serialize}; // TODO: Public APIs should return specific error struct/enum -pub async fn extract_trade( - name: String, - current_wallet: &Mutex>, +pub async fn extract_trade( + wallet: &W, transaction: Transaction, ) -> Result { - let wallet = current(&name, current_wallet).await?; - - let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))).await?; - let balances = compute_balances( - &wallet, + let txouts = wallet + .get_txouts(|utxo, txout| Ok(Some((utxo, txout)))) + .await?; + let balances = wallet.compute_balances( &txouts .iter() .map(|(_, txout)| txout) diff --git a/extension/wallet/src/lib_wallet/get_balances.rs b/extension/wallet/src/lib_wallet/get_balances.rs new file mode 100644 index 000000000..77c3fee08 --- /dev/null +++ b/extension/wallet/src/lib_wallet/get_balances.rs @@ -0,0 +1,10 @@ +use crate::lib_wallet::{BalanceEntry, Wallet}; +use anyhow::Result; + +pub async fn get_balances(wallet: &W) -> Result> { + let txouts = wallet.get_txouts(|_, txout| Ok(Some(txout))).await?; + + let balances = wallet.compute_balances(&txouts); + + Ok(balances) +} diff --git a/extension/wallet/src/lib_wallet/get_transaction_history.rs b/extension/wallet/src/lib_wallet/get_transaction_history.rs new file mode 100644 index 000000000..947dd8ba1 --- /dev/null +++ b/extension/wallet/src/lib_wallet/get_transaction_history.rs @@ -0,0 +1,13 @@ +use anyhow::Result; +use elements::Txid; + +use crate::lib_wallet::Wallet; + +pub async fn get_transaction_history(wallet: &W) -> Result> { + // We have a single address, so looking for the transaction + // history of said address is sufficient + let address = wallet.get_address(); + let history = wallet.fetch_transaction_history(&address).await?; + + Ok(history) +} diff --git a/extension/wallet/src/wallet/make_create_swap_payload.rs b/extension/wallet/src/lib_wallet/make_create_swap_payload.rs similarity index 51% rename from extension/wallet/src/wallet/make_create_swap_payload.rs rename to extension/wallet/src/lib_wallet/make_create_swap_payload.rs index 3d8ead3e0..646ab3f53 100644 --- a/extension/wallet/src/wallet/make_create_swap_payload.rs +++ b/extension/wallet/src/lib_wallet/make_create_swap_payload.rs @@ -1,97 +1,63 @@ -use crate::{ - wallet::{current, get_txouts, CreateSwapPayload, SwapUtxo, Wallet}, - BTC_ASSET_ID, USDT_ASSET_ID, -}; +use crate::lib_wallet::{CreateSwapPayload, SwapUtxo, Wallet}; use bdk::bitcoin::Amount; use coin_selection::{self, coin_select}; use elements::{secp256k1_zkp::SECP256K1, AssetId, OutPoint}; use estimate_transaction_size::avg_vbytes; -use futures::lock::Mutex; -use wasm_bindgen::UnwrapThrowExt; -pub async fn make_buy_create_swap_payload( - name: String, - current_wallet: &Mutex>, +pub async fn make_buy_create_swap_payload( + wallet: &W, sell_amount: Amount, + btc_asset_id: AssetId, + usdt_asset_id: AssetId, ) -> Result { - let btc_asset_id = { - let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - let usdt_asset_id = { - let guard = USDT_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - - make_create_swap_payload( - name, - current_wallet, - sell_amount, - usdt_asset_id, - btc_asset_id, - ) - .await + make_create_swap_payload(wallet, sell_amount, usdt_asset_id, btc_asset_id).await } -pub async fn make_sell_create_swap_payload( - name: String, - current_wallet: &Mutex>, +pub async fn make_sell_create_swap_payload( + wallet: &W, sell_amount: Amount, + btc_asset_id: AssetId, ) -> Result { - let btc_asset_id = { - let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - make_create_swap_payload( - name, - current_wallet, - sell_amount, - btc_asset_id, - btc_asset_id, - ) - .await + make_create_swap_payload(wallet, sell_amount, btc_asset_id, btc_asset_id).await } -async fn make_create_swap_payload( - name: String, - current_wallet: &Mutex>, +async fn make_create_swap_payload( + wallet: &W, sell_amount: Amount, sell_asset: AssetId, fee_asset: AssetId, ) -> 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; + let utxos = wallet + .get_txouts(|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 - } + 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)?; + .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 @@ -130,8 +96,6 @@ async fn make_create_swap_payload( #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("Wallet is not loaded: {0}")] - LoadWallet(anyhow::Error), #[error("Coin selection: {0}")] CoinSelection(coin_selection::Error), #[error("Failed to get transaction outputs: {0}")] diff --git a/extension/wallet/src/wallet/make_loan_request.rs b/extension/wallet/src/lib_wallet/make_loan_request.rs similarity index 54% rename from extension/wallet/src/wallet/make_loan_request.rs rename to extension/wallet/src/lib_wallet/make_loan_request.rs index b586c4d55..6afef209b 100644 --- a/extension/wallet/src/wallet/make_loan_request.rs +++ b/extension/wallet/src/lib_wallet/make_loan_request.rs @@ -1,38 +1,20 @@ -use crate::{ - storage::Storage, - wallet::{current, get_txouts, Wallet}, - BTC_ASSET_ID, DEFAULT_SAT_PER_VBYTE, USDT_ASSET_ID, -}; +use crate::lib_wallet::{Wallet, DEFAULT_SAT_PER_VBYTE}; use baru::{ input::Input, loan::{Borrower0, LoanRequest}, }; use coin_selection::{self, coin_select}; -use elements::{bitcoin::util::amount::Amount, secp256k1_zkp::SECP256K1, OutPoint}; +use elements::{bitcoin::util::amount::Amount, secp256k1_zkp::SECP256K1, AssetId, OutPoint}; use estimate_transaction_size::avg_vbytes; -use futures::lock::Mutex; use rand::thread_rng; -use wasm_bindgen::UnwrapThrowExt; -pub async fn make_loan_request( - name: String, - current_wallet: &Mutex>, +pub async fn make_loan_request( + wallet: &W, collateral_amount: Amount, -) -> Result { - let btc_asset_id = { - let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - let usdt_asset_id = { - let guard = USDT_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - + btc_asset_id: AssetId, + usdt_asset_id: AssetId, +) -> Result<(Borrower0, LoanRequest), Error> { let (address, blinding_key) = { - let wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - let address = wallet.get_address(); let blinding_key = wallet.blinding_key(); @@ -41,38 +23,37 @@ pub async fn make_loan_request( 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 { + let utxos = wallet + .get_txouts(|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, - 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 - } + candidate_asset + ); + None + } + }) }) - }) - .await?; + .await?; // Bob currently hardcodes a fee-rate of 1 sat / vbyte, hence // there is no need for us to perform fee estimation. Later @@ -125,29 +106,15 @@ pub async fn make_loan_request( .await .map_err(Error::BuildBorrowerState)?; - let storage = Storage::local_storage().map_err(Error::Storage)?; - storage - .set_item( - "borrower_state", - serde_json::to_string(&borrower).map_err(Error::Serialize)?, - ) - .map_err(Error::Save)?; + let loan_request = borrower.loan_request(); - Ok(borrower.loan_request()) + Ok((borrower, loan_request)) } #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("Wallet is not loaded {0}")] - LoadWallet(anyhow::Error), #[error("Failed to construct borrower state: {0}")] BuildBorrowerState(anyhow::Error), - #[error("Storage error: {0}")] - Storage(anyhow::Error), - #[error("Failed to save item to storage: {0}")] - Save(anyhow::Error), - #[error("Serialization failed: {0}")] - Serialize(serde_json::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/lib_wallet/repay_loan.rs similarity index 50% rename from extension/wallet/src/wallet/repay_loan.rs rename to extension/wallet/src/lib_wallet/repay_loan.rs index bf0fd0315..aef8354d8 100644 --- a/extension/wallet/src/wallet/repay_loan.rs +++ b/extension/wallet/src/lib_wallet/repay_loan.rs @@ -3,80 +3,58 @@ use coin_selection::coin_select; use elements::{ bitcoin::util::amount::Amount, secp256k1_zkp::SECP256K1, sighash::SigHashCache, OutPoint, Txid, }; -use futures::lock::Mutex; use rand::thread_rng; -use crate::{ - esplora::{broadcast, fetch_transaction}, - storage::Storage, - wallet::{current, get_txouts, LoanDetails}, - Wallet, DEFAULT_SAT_PER_VBYTE, -}; +use crate::lib_wallet::{Wallet, DEFAULT_SAT_PER_VBYTE}; // TODO: Parts of the implementation are very similar to what we do in // `sign_and_send_swap_transaction`. We could extract common // functionality into crate-local functions -pub async fn repay_loan( - name: String, - current_wallet: &Mutex>, +pub async fn repay_loan( + wallet: &W, loan_txid: Txid, + borrower1: Borrower1, ) -> Result { // TODO: Only abort early if this fails because the transaction // hasn't been mined - if fetch_transaction(loan_txid).await.is_err() { + if wallet.fetch_transaction(loan_txid).await.is_err() { return Err(Error::NoLoan); } - - let storage = Storage::local_storage().map_err(Error::Storage)?; - - let borrower = storage - .get_item::(&format!("loan_state:{}", loan_txid)) - .map_err(Error::Load)? - .ok_or(Error::EmptyState)?; - let borrower = serde_json::from_str::(&borrower).map_err(Error::Deserialize)?; - - let blinding_key = { - let wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - wallet.blinding_key() - }; + let blinding_key = wallet.blinding_key(); let coin_selector = { - let name = name.clone(); |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 { + let utxos = wallet + .get_txouts(|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, - 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 - } + candidate_asset + ); + None + } + }) }) - }) - .await?; + .await?; // We are selecting coins with an asset which cannot be // used to pay for fees @@ -112,8 +90,9 @@ pub async fn repay_loan( }; 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 txouts = wallet + .get_txouts(|utxo, txout| Ok(Some((utxo, txout)))) + .await?; let mut cache = SigHashCache::new(&transaction); @@ -141,7 +120,7 @@ pub async fn repay_loan( SECP256K1, &mut cache, index, - &wallet.secret_key, + &wallet.secret_key(), output.value, ); @@ -156,7 +135,7 @@ pub async fn repay_loan( Ok(transaction) }; - let loan_repayment_tx = borrower + let loan_repayment_tx = borrower1 .loan_repayment_transaction( &mut thread_rng(), SECP256K1, @@ -167,34 +146,11 @@ pub async fn repay_loan( .await .map_err(Error::BuildTransaction)?; - let repayment_txid = broadcast(loan_repayment_tx) + let repayment_txid = wallet + .broadcast(loan_repayment_tx) .await .map_err(Error::SendTransaction)?; - // TODO: Make sure that we can safely forget this i.e. sufficient - // confirmations - storage - .remove_item(&format!("loan_state:{}", loan_txid)) - .map_err(Error::Delete)?; - - let open_loans = match storage - .get_item::("open_loans") - .map_err(Error::Load)? - { - Some(open_loans) => serde_json::from_str(&open_loans).map_err(Error::Deserialize)?, - None => Vec::::new(), - }; - let open_loans = open_loans - .iter() - .take_while(|details| loan_txid != details.txid) - .collect::>(); - storage - .set_item( - "open_loans", - serde_json::to_string(&open_loans).map_err(Error::Serialize)?, - ) - .map_err(Error::Save)?; - Ok(repayment_txid) } @@ -202,22 +158,6 @@ pub async fn repay_loan( pub enum Error { #[error("Loan transaction not found in the blockchain")] NoLoan, - #[error("Storage error: {0}")] - Storage(anyhow::Error), - #[error("Failed to load item from storage: {0}")] - Load(anyhow::Error), - #[error("Deserialization failed: {0}")] - Deserialize(serde_json::Error), - #[error("Serialization failed: {0}")] - Serialize(serde_json::Error), - #[error("Failed to delete item from storage: {0}")] - Delete(anyhow::Error), - #[error("Failed to save item to storage: {0}")] - Save(anyhow::Error), - #[error("Loaded empty loan state")] - EmptyState, - #[error("Wallet is not loaded: {0}")] - LoadWallet(anyhow::Error), #[error("Failed to construct loan repayment transaction: {0}")] BuildTransaction(anyhow::Error), #[error("Failed to broadcast transaction: {0}")] diff --git a/extension/wallet/src/wallet/sign_and_send_swap_transaction.rs b/extension/wallet/src/lib_wallet/sign_and_send_swap_transaction.rs similarity index 74% rename from extension/wallet/src/wallet/sign_and_send_swap_transaction.rs rename to extension/wallet/src/lib_wallet/sign_and_send_swap_transaction.rs index d048dc7c7..41a3e52b3 100644 --- a/extension/wallet/src/wallet/sign_and_send_swap_transaction.rs +++ b/extension/wallet/src/lib_wallet/sign_and_send_swap_transaction.rs @@ -1,22 +1,14 @@ -use crate::{ - esplora::broadcast, - wallet::{current, get_txouts, Wallet}, -}; +use crate::lib_wallet::Wallet; use anyhow::Result; 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>, +pub async fn sign_and_send_swap_transaction( + wallet: &W, transaction: Transaction, ) -> Result { - let wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - - let txouts = get_txouts(&wallet, |utxo, txout| Ok(Some((utxo, txout)))) + let txouts = wallet + .get_txouts(|utxo, txout| Ok(Some((utxo, txout)))) .await .map_err(Error::GetTxOuts)?; @@ -42,7 +34,7 @@ pub(crate) async fn sign_and_send_swap_transaction( SECP256K1, &mut cache, index, - &wallet.secret_key, + &wallet.secret_key(), output.value, ); @@ -59,15 +51,13 @@ pub(crate) async fn sign_and_send_swap_transaction( .await .map_err(Error::Sign)?; - let txid = broadcast(transaction).await.map_err(Error::Send)?; + let txid = wallet.broadcast(transaction).await.map_err(Error::Send)?; Ok(txid) } #[derive(Debug, thiserror::Error)] 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}")] diff --git a/extension/wallet/src/lib_wallet/sign_loan.rs b/extension/wallet/src/lib_wallet/sign_loan.rs new file mode 100644 index 000000000..519b6f11b --- /dev/null +++ b/extension/wallet/src/lib_wallet/sign_loan.rs @@ -0,0 +1,65 @@ +use baru::{loan::Borrower1, swap::sign_with_key}; +use elements::{secp256k1_zkp::SECP256K1, sighash::SigHashCache, Transaction}; + +use crate::lib_wallet::Wallet; + +pub async fn sign_loan( + wallet: &W, + borrower: Borrower1, +) -> Result { + let loan_transaction = borrower + .sign(|mut transaction| async { + let txouts = wallet + .get_txouts(|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) + }) + .await + .map_err(Error::Sign)?; + + Ok(loan_transaction) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to sign transaction: {0}")] + Sign(anyhow::Error), +} diff --git a/extension/wallet/src/wallet/withdraw_everything_to.rs b/extension/wallet/src/lib_wallet/withdraw_everything_to.rs similarity index 88% rename from extension/wallet/src/wallet/withdraw_everything_to.rs rename to extension/wallet/src/lib_wallet/withdraw_everything_to.rs index fa2258765..557aebdec 100644 --- a/extension/wallet/src/wallet/withdraw_everything_to.rs +++ b/extension/wallet/src/lib_wallet/withdraw_everything_to.rs @@ -1,8 +1,4 @@ -use crate::{ - esplora, - wallet::{current, get_txouts, Wallet, DEFAULT_SAT_PER_VBYTE}, - BTC_ASSET_ID, -}; +use crate::lib_wallet::{Wallet, DEFAULT_SAT_PER_VBYTE}; use anyhow::{bail, Context, Result}; use elements::{ hashes::{hash160, Hash}, @@ -10,37 +6,30 @@ use elements::{ script::Builder, secp256k1_zkp::{rand, Message, SECP256K1}, sighash::SigHashCache, - Address, OutPoint, SigHashType, Transaction, TxIn, TxOut, TxOutSecrets, Txid, + Address, AssetId, 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( - name: String, - current_wallet: &Mutex>, +pub async fn withdraw_everything_to( + wallet: &W, address: Address, + btc_asset_id: AssetId, ) -> Result { - let btc_asset_id = { - let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - 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 txouts = wallet + .get_txouts(|utxo, txout| { + let unblinded_txout = txout.unblind(SECP256K1, blinding_key)?; + Ok(Some((utxo, txout, unblinded_txout))) + }) + .await?; let prevout_values = txouts .iter() @@ -55,7 +44,7 @@ pub async fn withdraw_everything_to( }) .collect::>(); - let fee_estimates = esplora::get_fee_estimates().await?; + let fee_estimates = wallet.get_fee_estimates().await?; let estimated_virtual_size = estimate_virtual_size(prevout_values.len() as u64, txouts.len() as u64); @@ -209,7 +198,7 @@ pub async fn withdraw_everything_to( for (index, input) in transaction.input.iter_mut().enumerate() { input.witness.script_witness = { - let hash = hash160::Hash::hash(&wallet.get_public_key().serialize()); + let hash = hash160::Hash::hash(&wallet.public_key().serialize()); let script = Builder::new() .push_opcode(opcodes::all::OP_DUP) .push_opcode(opcodes::all::OP_HASH160) @@ -225,19 +214,20 @@ pub async fn withdraw_everything_to( SigHashType::All, ); - let sig = SECP256K1.sign(&Message::from(sighash), &wallet.secret_key); + let sig = SECP256K1.sign(&Message::from(sighash), &wallet.secret_key()); let mut serialized_signature = sig.serialize_der().to_vec(); serialized_signature.push(SigHashType::All as u8); vec![ serialized_signature, - wallet.get_public_key().serialize().to_vec(), + wallet.public_key().serialize().to_vec(), ] } } - let txid = esplora::broadcast(transaction) + let txid = wallet + .broadcast(transaction) .await .context("failed to broadcast transaction via esplora")?; diff --git a/extension/wallet/src/storage.rs b/extension/wallet/src/storage.rs index beedd6233..a575399d3 100644 --- a/extension/wallet/src/storage.rs +++ b/extension/wallet/src/storage.rs @@ -1,9 +1,8 @@ +use crate::lib_wallet::LoanDetails; use anyhow::{Context, Result}; use std::{error::Error as StdError, str::FromStr}; use web_sys::window; -use crate::LoanDetails; - /// A wrapper type around the cache storage. pub struct Storage { inner: web_sys::Storage, diff --git a/extension/wallet/src/wallet.rs b/extension/wallet/src/wallet.rs index ee73251be..5c3c62260 100644 --- a/extension/wallet/src/wallet.rs +++ b/extension/wallet/src/wallet.rs @@ -1,102 +1,46 @@ -use crate::{ - assets::{self, lookup}, - esplora, - esplora::Utxo, - CHAIN, DEFAULT_SAT_PER_VBYTE, -}; +use std::{convert::Infallible, fmt, str}; + use aes_gcm_siv::{ aead::{Aead, NewAead}, Aes256GcmSiv, }; -use anyhow::{bail, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; use elements::{ bitcoin::{ self, secp256k1::{SecretKey, SECP256K1}, - util::amount::Amount, }, - confidential, + encode::{deserialize, serialize_hex}, secp256k1_zkp::{rand, PublicKey}, - Address, AssetId, OutPoint, TxOut, Txid, -}; -use futures::{ - lock::{MappedMutexGuard, Mutex, MutexGuard}, - stream::FuturesUnordered, - StreamExt, TryStreamExt, + Address, Transaction, Txid, }; +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 reqwest::StatusCode; use sha2::{digest::generic_array::GenericArray, Sha256}; -use std::{ - convert::Infallible, - fmt, - ops::{Add, Sub}, - str, -}; use wasm_bindgen::UnwrapThrowExt; -pub use create_new::create_new; -pub use extract_loan::{extract_loan, Error as ExtractLoanError}; -pub use extract_trade::{extract_trade, Trade}; -pub use get_address::get_address; -pub use get_balances::get_balances; +use crate::{ + lib_wallet, + lib_wallet::{FeeEstimatesResponse, Utxo}, + CHAIN, ESPLORA_API_URL, +}; + +pub use assets::lookup; +pub use create_new_waves::create_new_waves; pub use get_status::{get_status, WalletStatus}; -pub use get_transaction_history::get_transaction_history; pub use load_existing::load_existing; -pub use make_create_swap_payload::{ - make_buy_create_swap_payload, make_sell_create_swap_payload, Error as MakePayloadError, -}; -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; pub use unload_current::unload_current; -pub use withdraw_everything_to::withdraw_everything_to; -mod create_new; -mod extract_loan; -mod extract_trade; -mod get_address; -mod get_balances; +mod assets; +mod create_new_waves; mod get_status; -mod get_transaction_history; mod load_existing; -mod make_create_swap_payload; -mod make_loan_request; -mod repay_loan; -mod sign_and_send_swap_transaction; -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>( +pub async fn current<'n, 'w>( name: &'n str, current_wallet: &'w Mutex>, ) -> Result, Wallet>> { @@ -115,7 +59,7 @@ pub struct Wallet { name: String, encryption_key: [u8; 32], secret_key: SecretKey, - sk_salt: [u8; 32], + pub(crate) sk_salt: [u8; 32], } const SECRET_KEY_ENCRYPTION_NONCE: &[u8; 12] = b"SECRET_KEY!!"; @@ -172,31 +116,13 @@ impl Wallet { 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 secret key with the encryption key. /// /// # Choice of nonce /// /// We store the secret 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_secret_key(&self) -> Result> { + pub(crate) fn encrypted_secret_key(&self) -> Result> { let cipher = Aes256GcmSiv::new(&GenericArray::from_slice(&self.encryption_key)); let enc_sk = cipher .encrypt( @@ -208,29 +134,6 @@ impl Wallet { 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 @@ -257,191 +160,229 @@ impl Wallet { } } -#[derive(Default)] -pub struct ListOfWallets(Vec); - -impl ListOfWallets { - fn has(&self, wallet: &str) -> bool { - self.0.iter().any(|w| w == wallet) - } +#[async_trait] +impl lib_wallet::Wallet for Wallet { + 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()); - fn add(&mut self, wallet: String) { - self.0.push(wallet); + Address::p2wpkh( + &bitcoin::PublicKey { + compressed: true, + key: public_key, + }, + Some(blinding_key), + chain.into(), + ) } -} -impl str::FromStr for ListOfWallets { - type Err = Infallible; + /// 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()); - fn from_str(s: &str) -> Result { - let split = s.split('\t'); + let mut bk = [0u8; 32]; + h.expand(b"BLINDING_KEY", &mut bk) + .expect("output length aligns with sha256"); - Ok(ListOfWallets(split.map(|s| s.to_owned()).collect())) + SecretKey::from_slice(bk.as_ref()).expect("always a valid secret key") } -} -impl fmt::Display for ListOfWallets { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0.join("\t")) + fn secret_key(&self) -> SecretKey { + self.secret_key } -} -/// Represents the payload for creating a swap. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -pub struct CreateSwapPayload { - pub alice_inputs: Vec, - pub address: Address, - #[serde(with = "bdk::bitcoin::util::amount::serde::as_sat")] - pub amount: bdk::bitcoin::Amount, -} + /// Fetch the UTXOs of an address. + /// + /// UTXOs change over time and as such, this function never uses a cache. + async fn fetch_utxos(&self) -> Result> { + let address = self.get_address(); + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; -#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] -pub struct SwapUtxo { - pub outpoint: OutPoint, - pub blinding_key: SecretKey, -} + let path = format!("address/{}/utxo", address); + let esplora_url = esplora_url.join(path.as_str())?; + let response = reqwest::get(esplora_url.clone()) + .await + .context("failed to fetch UTXOs")?; -/// 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, -} + if response.status() == StatusCode::NOT_FOUND { + log::debug!( + "GET {} returned 404, defaulting to empty UTXO set", + esplora_url + ); -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, + return Ok(Vec::new()); } + + if !response.status().is_success() { + let error_body = response.text().await?; + return Err(anyhow!( + "failed to fetch utxos, esplora returned '{}'", + error_body + )); + } + + response + .json::>() + .await + .context("failed to deserialize response") } -} -/// 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() -} + /// 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. + async fn fetch_transaction(&self, txid: Txid) -> Result { + let esplora_url = { + let guard = ESPLORA_API_URL.lock().expect_throw("can get lock"); + guard.clone() + }; -#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct TradeSide { - pub ticker: String, - pub amount: Decimal, - pub balance_before: Decimal, - pub balance_after: Decimal, -} + //todo!(consider caching) + + let client = reqwest::Client::new(); + let body = client + .get(format!("{}tx/{}/hex", esplora_url, txid)) + .send() + .await?; + let body_text = body + .text() + .await + .with_context(|| "response is not a string")?; -impl TradeSide { - fn new_sell(asset: AssetId, amount: u64, current_balance: Decimal) -> Result { - Self::new(asset, amount, current_balance, Decimal::sub) + Ok(deserialize(&hex::decode(body_text)?)?) } - fn new_buy(asset: AssetId, amount: u64, current_balance: Decimal) -> Result { - Self::new(asset, amount, current_balance, Decimal::add) + /// 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. + async fn fetch_transaction_history(&self, 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()) } - fn new( - asset: AssetId, - amount: u64, - current_balance: Decimal, - balance_after: impl Fn(Decimal, Decimal) -> Decimal, - ) -> Result { - let (ticker, precision) = assets::lookup(asset).context("asset not found")?; + async fn broadcast(&self, 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 mut amount = Decimal::from(amount); - amount - .set_scale(precision as u32) - .expect("precision must be < 28"); + let response = client + .post(esplora_url.clone()) + .body(serialize_hex(&tx)) + .send() + .await?; - Ok(Self { - ticker: ticker.to_owned(), - amount, - balance_before: current_balance, - balance_after: balance_after(current_balance, amount), - }) + 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) + } + + async fn get_fee_estimates(&self) -> 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) } } -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct LoanDetails { - pub collateral: TradeSide, - pub principal: TradeSide, - pub principal_repayment: Decimal, - // TODO: Express as target date or number of days instead? - pub term: u64, - pub txid: Txid, +#[derive(Default)] +pub struct ListOfWallets(Vec); + +impl ListOfWallets { + pub(crate) fn has(&self, wallet: &str) -> bool { + self.0.iter().any(|w| w == wallet) + } + + pub(crate) fn add(&mut self, wallet: String) { + self.0.push(wallet); + } } -impl LoanDetails { - #[allow(clippy::too_many_arguments)] - pub fn new( - collateral_asset: AssetId, - collateral_amount: Amount, - collateral_balance: Decimal, - principal_asset: AssetId, - principal_amount: Amount, - principal_balance: Decimal, - timelock: u64, - txid: Txid, - ) -> Result { - let collateral = TradeSide::new_sell( - collateral_asset, - collateral_amount.as_sat(), - collateral_balance, - )?; - - let principal = TradeSide::new_buy( - principal_asset, - principal_amount.as_sat(), - principal_balance, - )?; +impl str::FromStr for ListOfWallets { + type Err = Infallible; - Ok(Self { - collateral, - principal_repayment: principal.amount, - principal, - term: timelock, - txid, - }) + fn from_str(s: &str) -> Result { + let split = s.split('\t'); + + Ok(ListOfWallets(split.map(|s| s.to_owned()).collect())) + } +} + +impl fmt::Display for ListOfWallets { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.join("\t")) } } @@ -480,7 +421,7 @@ mod browser_tests { set_elements_chain_in_local_storage(); let current_wallet = Mutex::default(); - create_new("wallet-1".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-1".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); @@ -492,7 +433,7 @@ mod browser_tests { #[wasm_bindgen_test] pub async fn given_a_wallet_when_unloaded_cannot_get_address() { let current_wallet = Mutex::default(); - create_new("wallet-2".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-2".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); @@ -511,10 +452,10 @@ mod browser_tests { pub async fn cannot_create_two_wallets_with_same_name() { let current_wallet = Mutex::default(); - create_new("wallet-3".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-3".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); - let error = create_new("wallet-3".to_owned(), "foo".to_owned(), ¤t_wallet) + let error = create_new_waves("wallet-3".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap_err(); @@ -528,10 +469,10 @@ mod browser_tests { pub async fn cannot_load_multiple_wallets_at_the_same_time() { let current_wallet = Mutex::default(); - create_new("wallet-4".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-4".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); - create_new("wallet-5".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-5".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); @@ -549,7 +490,7 @@ mod browser_tests { pub async fn cannot_load_wallet_with_wrong_password() { let current_wallet = Mutex::default(); - create_new("wallet-6".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-6".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); unload_current(¤t_wallet).await; @@ -576,7 +517,7 @@ mod browser_tests { pub async fn new_wallet_is_automatically_loaded() { let current_wallet = Mutex::default(); - create_new("wallet-7".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-7".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); let status = get_status("wallet-7".to_owned(), ¤t_wallet) @@ -601,7 +542,7 @@ mod browser_tests { pub async fn secret_key_can_be_successfully_decrypted() { let current_wallet = Mutex::default(); - create_new("wallet-9".to_owned(), "foo".to_owned(), ¤t_wallet) + create_new_waves("wallet-9".to_owned(), "foo".to_owned(), ¤t_wallet) .await .unwrap(); let initial_sk = { diff --git a/extension/wallet/src/assets.rs b/extension/wallet/src/wallet/assets.rs similarity index 100% rename from extension/wallet/src/assets.rs rename to extension/wallet/src/wallet/assets.rs diff --git a/extension/wallet/src/wallet/create_new.rs b/extension/wallet/src/wallet/create_new_waves.rs similarity index 98% rename from extension/wallet/src/wallet/create_new.rs rename to extension/wallet/src/wallet/create_new_waves.rs index e2c71c8e9..588f20dab 100644 --- a/extension/wallet/src/wallet/create_new.rs +++ b/extension/wallet/src/wallet/create_new_waves.rs @@ -7,7 +7,7 @@ use crate::{ wallet::{ListOfWallets, Wallet}, }; -pub async fn create_new( +pub async fn create_new_waves( name: String, password: String, current_wallet: &Mutex>, diff --git a/extension/wallet/src/wallet/extract_loan.rs b/extension/wallet/src/wallet/extract_loan.rs deleted file mode 100644 index 297b50ef1..000000000 --- a/extension/wallet/src/wallet/extract_loan.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::{ - storage::Storage, - wallet::{compute_balances, current, get_txouts, Wallet}, - LoanDetails, BTC_ASSET_ID, USDT_ASSET_ID, -}; -use baru::loan::{Borrower0, LoanResponse}; -use elements::secp256k1_zkp::SECP256K1; -use futures::lock::Mutex; -use wasm_bindgen::UnwrapThrowExt; - -pub async fn extract_loan( - name: String, - current_wallet: &Mutex>, - loan_response: LoanResponse, -) -> Result { - let btc_asset_id = { - let guard = BTC_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - let usdt_asset_id = { - let guard = USDT_ASSET_ID.lock().expect_throw("can get lock"); - *guard - }; - - let wallet = current(&name, current_wallet) - .await - .map_err(Error::LoadWallet)?; - - 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 - .get_item::("borrower_state") - .map_err(Error::Load)? - .ok_or(Error::EmptyState)?; - let borrower = serde_json::from_str::(&borrower).map_err(Error::Deserialize)?; - - let timelock = loan_response.timelock; - let borrower = borrower - .interpret(SECP256K1, loan_response) - .map_err(Error::InterpretLoanResponse)?; - - let collateral_balance = balances - .iter() - .find_map(|entry| { - if entry.asset == btc_asset_id { - Some(entry.value) - } else { - None - } - }) - .ok_or(Error::InsufficientCollateral)?; - - let principal_balance = balances - .iter() - .find_map(|entry| { - if entry.asset == usdt_asset_id { - Some(entry.value) - } else { - None - } - }) - .unwrap_or_default(); - - let loan_txid = borrower.loan_transaction.txid(); - let loan_details = LoanDetails::new( - btc_asset_id, - borrower.collateral_amount, - collateral_balance, - usdt_asset_id, - borrower.principal_tx_out_amount, - principal_balance, - timelock, - loan_txid, - ) - .map_err(Error::LoanDetails)?; - - storage - .set_item( - "borrower_state", - serde_json::to_string(&(borrower, loan_details.clone())).map_err(Error::Serialize)?, - ) - .map_err(Error::Save)?; - - Ok(loan_details) -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("Failed to deserialise loan response: {0}")] - 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}")] - Load(anyhow::Error), - #[error("Failed to save item to storage: {0}")] - Save(anyhow::Error), - #[error("Loaded empty borrower state")] - EmptyState, - #[error("Deserialization failed: {0}")] - Deserialize(serde_json::Error), - #[error("Serialization failed: {0}")] - Serialize(serde_json::Error), - #[error("Failed to interpret loan response: {0}")] - InterpretLoanResponse(anyhow::Error), - #[error("Not enough collateral to put up for loan")] - InsufficientCollateral, - #[error("Failed to build loan details: {0}")] - LoanDetails(anyhow::Error), -} diff --git a/extension/wallet/src/wallet/get_address.rs b/extension/wallet/src/wallet/get_address.rs deleted file mode 100644 index d1f6b27ae..000000000 --- a/extension/wallet/src/wallet/get_address.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::wallet::{current, Wallet}; -use anyhow::Result; -use elements::Address; -use futures::lock::Mutex; - -pub async fn get_address(name: String, current_wallet: &Mutex>) -> Result
{ - let wallet = current(&name, current_wallet).await?; - - let address = wallet.get_address(); - - Ok(address) -} diff --git a/extension/wallet/src/wallet/get_balances.rs b/extension/wallet/src/wallet/get_balances.rs deleted file mode 100644 index 3629aab8f..000000000 --- a/extension/wallet/src/wallet/get_balances.rs +++ /dev/null @@ -1,16 +0,0 @@ -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>, -) -> Result> { - let wallet = current(name, current_wallet).await?; - - 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_transaction_history.rs b/extension/wallet/src/wallet/get_transaction_history.rs deleted file mode 100644 index 2ad7922e4..000000000 --- a/extension/wallet/src/wallet/get_transaction_history.rs +++ /dev/null @@ -1,19 +0,0 @@ -use anyhow::Result; -use elements::Txid; -use futures::lock::Mutex; - -use crate::{esplora, wallet::current, Wallet}; - -pub async fn get_transaction_history( - name: String, - current_wallet: &Mutex>, -) -> Result> { - 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 = esplora::fetch_transaction_history(&address).await?; - - Ok(history) -} diff --git a/extension/wallet/src/wallet/sign_loan.rs b/extension/wallet/src/wallet/sign_loan.rs deleted file mode 100644 index 97bb50a75..000000000 --- a/extension/wallet/src/wallet/sign_loan.rs +++ /dev/null @@ -1,117 +0,0 @@ -use baru::{loan::Borrower1, swap::sign_with_key}; -use elements::{secp256k1_zkp::SECP256K1, sighash::SigHashCache, Transaction}; -use futures::lock::Mutex; - -use crate::{ - storage::Storage, - wallet::{current, get_txouts, LoanDetails}, - Wallet, -}; - -pub(crate) async fn sign_loan( - name: String, - current_wallet: &Mutex>, -) -> Result { - let storage = Storage::local_storage().map_err(Error::Storage)?; - let borrower = storage - .get_item::("borrower_state") - .map_err(Error::Load)? - .ok_or(Error::EmptyState)?; - let (borrower, loan_details) = - serde_json::from_str::<(Borrower1, LoanDetails)>(&borrower).map_err(Error::Deserialize)?; - - let loan_transaction = borrower - .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) - }) - .await - .map_err(Error::Sign)?; - - // We don't broadcast this transaction ourselves, but we expect - // the lender to do so very soon. We therefore save the borrower - // state so that we can later on build, sign and broadcast the - // repayment transaction - - let mut open_loans = match storage - .get_item::("open_loans") - .map_err(Error::Load)? - { - Some(open_loans) => serde_json::from_str(&open_loans).map_err(Error::Deserialize)?, - None => Vec::::new(), - }; - - open_loans.push(loan_details); - storage - .set_item( - "open_loans", - serde_json::to_string(&open_loans).map_err(Error::Serialize)?, - ) - .map_err(Error::Save)?; - - storage - .set_item( - &format!("loan_state:{}", loan_transaction.txid()), - serde_json::to_string(&borrower).map_err(Error::Serialize)?, - ) - .map_err(Error::Save)?; - - Ok(loan_transaction) -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("Storage error: {0}")] - Storage(anyhow::Error), - #[error("Failed to load item from storage: {0}")] - Load(anyhow::Error), - #[error("Loaded empty borrower state")] - EmptyState, - #[error("Failed to save item to storage: {0}")] - Save(anyhow::Error), - #[error("Deserialization failed: {0}")] - Deserialize(serde_json::Error), - #[error("Serialization failed: {0}")] - Serialize(serde_json::Error), - #[error("Failed to sign transaction: {0}")] - Sign(anyhow::Error), -}