From 8118d55624af4a45b9104532207d18d488b79b85 Mon Sep 17 00:00:00 2001 From: Lucas Soriano del Pino Date: Tue, 10 Aug 2021 17:09:20 +1000 Subject: [PATCH] Add wallet functionality to baru Wallet which allows users to select coins through a backend configured by them via the `GetTxOuts` trait. The provided wallet comes with a UTXO cache which this is updated using `Wallet::sync`. This allows users of the library to optimise the number of requests to their backend. Users can also sign said UTXOs by calling `Wallet::sign`. --- CHANGELOG.md | 11 + Cargo.toml | 12 +- src/avg_vbytes.rs | 17 + src/coin_selection.rs | 356 ++++++++++++++++ src/estimate_transaction_size.rs | 21 +- src/lib.rs | 6 +- src/loan.rs | 9 +- src/wallet.rs | 672 +++++++++++++++++++++++++++++++ tests/loan_protocol.rs | 65 ++- 9 files changed, 1102 insertions(+), 67 deletions(-) create mode 100644 src/avg_vbytes.rs create mode 100644 src/coin_selection.rs create mode 100644 src/wallet.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fe26d24..e3b5b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Wallet which allows users to select coins through a backend configured by them via the `GetUtxos` trait. + The provided wallet comes with a UTXO cache which this is updated using `Wallet::sync`. + This allows users of the library to optimise the number of requests to their backend. + Users can also sign said UTXOs by calling `Wallet::sign`. + +### Changed + +- The borrower can now choose the collateral inputs before calling `Borrower0::new`. + ### Fixed - The loan transaction no longer expects collateral and principal change outputs. diff --git a/Cargo.toml b/Cargo.toml index fd9ef8c..d29ee8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,19 +10,29 @@ license-file = "LICENSE" description = "Library to facilitate DeFi on Liquid" [dependencies] +aes-gcm-siv = { version = "0.9", features = ["std"] } anyhow = "1" +async-trait = "0.1" +bdk = { version = "0.4", default-features = false } +bip32 = { version = "0.2", features = ["secp256k1-ffi", "bip39"], default-features = false } conquer-once = "0.3" elements = { version = "0.18", features = ["serde-feature"] } elements-miniscript = { version = "0.1", features = ["use-serde"] } +futures = "0.3" hex = "0.4" +hkdf = { version = "0.10", features = ["std"] } +itertools = "0.10" log = "0.4" -rand = "0.6" +rand = { version = "0.6", features = ["wasm-bindgen"] } +reqwest = { version = "0.11", default-features = false, features = ["rustls", "json"] } rust_decimal = "1" serde = { version = "1", features = ["derive"] } +sha2 = "0.9" thiserror = "1" [dev-dependencies] elements-consensus = { git = "https://github.com/comit-network/rust-elements-consensus", rev = "ac88dbedcd019eef44f58499417dcdbeda994b0b" } link-cplusplus = "1" rand_chacha = "0.1" +serde_json = "1" tokio = { version = "1", default-features = false, features = ["macros", "rt"] } diff --git a/src/avg_vbytes.rs b/src/avg_vbytes.rs new file mode 100644 index 0000000..e24713a --- /dev/null +++ b/src/avg_vbytes.rs @@ -0,0 +1,17 @@ +//! 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 const INPUT: u64 = 107; +pub const OUTPUT: u64 = 1184; +pub const FEE: u64 = 41; diff --git a/src/coin_selection.rs b/src/coin_selection.rs new file mode 100644 index 0000000..0143ad4 --- /dev/null +++ b/src/coin_selection.rs @@ -0,0 +1,356 @@ +use crate::avg_vbytes; +use bdk::database::{BatchOperations, Database}; +use bdk::wallet::coin_selection::{ + BranchAndBoundCoinSelection, CoinSelectionAlgorithm, CoinSelectionResult, +}; +use elements::bitcoin::{Amount, Denomination}; +use elements::{AssetId, OutPoint, Script}; + +/// 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(crate) 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| { + bdk_utxo.outpoint.txid.to_string() == utxo.outpoint.txid.to_string() + && 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), +} + +/// Result of running the coin selection algorithm succesfully. +#[derive(Debug)] +pub struct Output { + pub coins: Vec, + pub target_amount: Amount, + pub recommended_fee: Amount, +} + +#[cfg(test)] +impl Output { + fn recommended_change(&self) -> Amount { + self.selected_amount() - self.target_amount - self.recommended_fee + } + + 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!() + } +} + +#[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, + } + } +} diff --git a/src/estimate_transaction_size.rs b/src/estimate_transaction_size.rs index f1cea3c..eaa5f74 100644 --- a/src/estimate_transaction_size.rs +++ b/src/estimate_transaction_size.rs @@ -1,23 +1,6 @@ -/// 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; -} +use crate::avg_vbytes; /// 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 { +pub(crate) 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/src/lib.rs b/src/lib.rs index e683ecb..9ab2142 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,10 @@ +pub mod avg_vbytes; +mod coin_selection; +mod estimate_transaction_size; pub mod input; pub mod loan; pub mod oracle; pub mod swap; +mod wallet; -mod estimate_transaction_size; +pub use wallet::{BalanceEntry, Chain, GetUtxos, Wallet, WrongChain}; diff --git a/src/loan.rs b/src/loan.rs index ae9cf6a..7a7f193 100644 --- a/src/loan.rs +++ b/src/loan.rs @@ -482,9 +482,9 @@ pub struct Borrower0 { impl Borrower0 { #[allow(clippy::too_many_arguments)] - pub async fn new( + pub async fn new( rng: &mut R, - coin_selector: CS, + collateral_inputs: Vec, address: Address, address_blinding_sk: SecretKey, collateral_amount: Amount, @@ -494,11 +494,8 @@ impl Borrower0 { ) -> Result where R: RngCore + CryptoRng, - CS: FnOnce(Amount, AssetId) -> CF, - CF: Future>>, { let keypair = make_keypair(rng); - let collateral_inputs = coin_selector(collateral_amount, bitcoin_asset_id).await?; Ok(Self { keypair, @@ -922,7 +919,7 @@ impl Lender0 { /// Interpret a loan request and performs lender logic. /// /// rate is expressed in usdt sats per btc, i.e. rate = 1 BTC / USDT - #[deprecated(note = "Use interpret_loan_request instead", since = "0.3.0")] + #[deprecated(note = "Use build_loan_transaction instead", since = "0.3.0")] pub async fn interpret( self, rng: &mut R, diff --git a/src/wallet.rs b/src/wallet.rs new file mode 100644 index 0000000..d2db14e --- /dev/null +++ b/src/wallet.rs @@ -0,0 +1,672 @@ +use crate::coin_selection; +use crate::coin_selection::coin_select; +use crate::estimate_transaction_size::estimate_virtual_size; +use crate::input::Input; +use crate::swap::sign_with_key; +use aes_gcm_siv::aead::generic_array::GenericArray; +use aes_gcm_siv::aead::{Aead, NewAead}; +use aes_gcm_siv::Aes256GcmSiv; +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use bip32::{ExtendedPrivateKey, Prefix}; +use elements::bitcoin::secp256k1::{PublicKey, SECP256K1}; +use elements::bitcoin::Amount; +use elements::hashes::{hash160, Hash}; +use elements::script::Builder; +use elements::secp256k1_zkp::{rand, Message, SecretKey}; +use elements::sighash::SigHashCache; +use elements::{ + confidential, opcodes, Address, AddressParams, AssetId, OutPoint, SigHashType, Transaction, + TxIn, TxOut, TxOutSecrets, +}; +use hkdf::Hkdf; +use itertools::Itertools; +use rand::{thread_rng, Rng}; +use sha2::Sha256; +use std::collections::HashMap; +use std::iter; +use std::str::FromStr; + +#[async_trait(?Send)] +pub trait GetUtxos { + async fn get_utxos(&self, address: Address) -> Result>; +} + +#[derive(Debug)] +pub struct Wallet { + name: String, + encryption_key: [u8; 32], + secret_key: SecretKey, + xprv: ExtendedPrivateKey, + sk_salt: [u8; 32], + chain: Chain, + utxo_cache: Vec<(OutPoint, TxOut)>, +} + +const SECRET_KEY_ENCRYPTION_NONCE: &[u8; 12] = b"SECRET_KEY!!"; + +impl Wallet { + pub async fn sync(&mut self, client: &impl GetUtxos) -> Result<()> { + self.utxo_cache = client.get_utxos(self.address()).await?; + Ok(()) + } + + pub fn initialize_new( + name: String, + password: String, + root_xprv: ExtendedPrivateKey, + chain: Chain, + ) -> 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, + chain, + secret_key: SecretKey::from_slice(&secret_key)?, + xprv: root_xprv, + utxo_cache: vec![], + }) + } + + pub fn initialize_existing( + name: String, + password: String, + xprv_ciphertext: String, + chain: Chain, + ) -> 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, + chain, + utxo_cache: vec![], + }) + } + + pub fn get_public_key(&self) -> PublicKey { + PublicKey::from_secret_key(SECP256K1, &self.secret_key) + } + + pub fn get_address(&self) -> Address { + Address::p2wpkh( + &elements::bitcoin::PublicKey { + compressed: false, + key: self.get_public_key(), + }, + Some(PublicKey::from_secret_key( + SECP256K1, + &self.blinding_secret_key(), + )), + self.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. + pub 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 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) + } + + pub fn name(&self) -> String { + self.name.clone() + } + + pub fn sk_salt(&self) -> [u8; 32] { + self.sk_salt + } + + pub fn address(&self) -> Address { + Address::p2wpkh( + &elements::bitcoin::PublicKey { + compressed: false, + key: self.get_public_key(), + }, + Some(self.blinding_public_key()), + self.chain.into(), + ) + } + + fn blinding_public_key(&self) -> PublicKey { + PublicKey::from_secret_key(SECP256K1, &self.blinding_secret_key()) + } + + /// 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. + pub fn blinding_secret_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") + } + + pub fn secret_key(&self) -> SecretKey { + self.secret_key + } + + pub fn compute_balances(&self) -> Vec { + let txouts = self.utxo_cache.clone(); + + let grouped_txouts = txouts + .iter() + .filter_map(|(_, txout)| match txout { + TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + .. + } => Some((*asset, *value)), + txout => match txout.unblind(SECP256K1, self.blinding_secret_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() + } + + pub fn sign(&self, mut transaction: Transaction) -> Transaction { + let mut cache = SigHashCache::new(&transaction); + + let witnesses = transaction + .clone() + .input + .iter() + .enumerate() + .filter_map(|(index, input)| { + self.utxo_cache + .iter() + .find(|(utxo, _)| *utxo == input.previous_output) + .map(|(_, txout)| (index, txout)) + }) + .map(|(index, output)| { + let script_witness = sign_with_key( + SECP256K1, + &mut cache, + index, + &self.secret_key(), + output.value, + ); + + (index, script_witness) + }) + .collect::>(); + + for (index, witness) in witnesses { + transaction.input[index].witness.script_witness = witness + } + + transaction + } + + pub fn coin_selection( + &self, + amount: Amount, + asset: AssetId, + fee_rate: f32, + fee_offset: Amount, + ) -> Result> { + let utxos = self + .utxo_cache + .iter() + .filter_map(|(utxo, txout)| { + let unblinded_txout = match txout.unblind(SECP256K1, self.blinding_secret_key()) { + Ok(txout) => txout, + Err(_) => { + log::warn!("could not unblind utxo {}, ignoring", utxo); + return None; + } + }; + let candidate_asset = unblinded_txout.asset; + + if candidate_asset == asset { + Some(( + coin_selection::Utxo { + outpoint: *utxo, + 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", + utxo, + candidate_asset + ); + None + } + }) + .collect::>(); + + let output = coin_select( + utxos.iter().map(|(utxo, _)| utxo).cloned().collect(), + amount, + fee_rate, + 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"); + + Input { + txin: coin.outpoint, + original_txout: original_txout.clone(), + blinding_key: self.blinding_secret_key(), + } + }) + .collect(); + Ok(selection) + } + + pub fn find_our_input_indices_in_transaction( + &self, + transaction: &Transaction, + ) -> Result> { + transaction + .input + .iter() + .filter_map(|txin| { + self.utxo_cache + .iter() + .map(|(utxo, txout)| { + let is_ours = *utxo == txin.previous_output; + 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, self.blinding_secret_key())?; + Some((unblinded.asset, unblinded.value)) + } + }) + }) + .find_map(|res| res.transpose()) + }) + .collect::>>() + } + + pub fn find_our_ouput_indices_in_transaction( + &self, + transaction: &Transaction, + ) -> Vec<(AssetId, u64)> { + transaction + .output + .iter() + .filter_map(|txout| match txout { + TxOut { + asset: confidential::Asset::Explicit(asset), + value: confidential::Value::Explicit(value), + script_pubkey, + .. + } if script_pubkey == &self.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, self.blinding_secret_key()) { + Ok(unblinded) => Some((unblinded.asset, unblinded.value)), + _ => None, + }, + }) + .collect() + } + + pub fn withdraw_everything_to_transaction( + &self, + address: Address, + btc_asset_id: AssetId, + fee_rate: f32, + ) -> Result { + if !address.is_blinded() { + bail!("can only withdraw to blinded addresses") + } + + let utxos = self + .utxo_cache + .clone() + .into_iter() + .filter_map(|(utxos, txout)| { + match txout.unblind(SECP256K1, self.blinding_secret_key()) { + Ok(unblinded_txout) => Some((utxos, txout, unblinded_txout)), + Err(_) => { + log::warn!("could not unblind utxo: {:?}, {:?}", utxos, txout); + None + } + } + }) + .collect::>(); + + let prevout_values = utxos + .iter() + .map(|(outpoint, confidential, _)| (outpoint, confidential.value)) + .collect::>(); + + let estimated_virtual_size = + estimate_virtual_size(prevout_values.len() as u64, utxos.len() as u64); + + let fee = (estimated_virtual_size as f32 * fee_rate) as u64; + + let txout_inputs = utxos + .iter() + .map(|(_, txout, secrets)| (txout.asset, secrets)) + .collect::>(); + + let txouts_grouped_by_asset = utxos + .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 = utxos + .iter() + .map(|(utxo, _, _)| TxIn { + previous_output: *utxo, + 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(&self.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 sig = SECP256K1.sign(&Message::from(sighash), &self.secret_key()); + + let mut serialized_signature = sig.serialize_der().to_vec(); + serialized_signature.push(SigHashType::All as u8); + + vec![ + serialized_signature, + self.get_public_key().serialize().to_vec(), + ] + } + } + + Ok(transaction) + } +} + +/// 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(Debug, Clone, Copy, PartialEq)] +pub 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}")] +pub struct WrongChain(String); diff --git a/tests/loan_protocol.rs b/tests/loan_protocol.rs index 92dbba4..c7d2131 100644 --- a/tests/loan_protocol.rs +++ b/tests/loan_protocol.rs @@ -37,16 +37,12 @@ async fn borrow_and_repay() { Borrower0::new( &mut rng, - { - let wallet = &mut wallet; - |amount, asset| async move { - wallet.coin_select( - // we need extra coins to pay for the fee - amount + Amount::from_sat(10_000), - asset, - ) - } - }, + wallet + .coin_select( + collateral_amount + Amount::from_sat(10_000), + bitcoin_asset_id, + ) + .unwrap(), address, blinding_sk, collateral_amount, @@ -177,16 +173,12 @@ async fn lend_and_liquidate() { Borrower0::new( &mut rng, - { - let wallet = &mut wallet; - |amount, asset| async move { - wallet.coin_select( - // we need extra coins to pay for the fee - amount + Amount::from_sat(10_000), - asset, - ) - } - }, + wallet + .coin_select( + collateral_amount + Amount::from_sat(10_000), + bitcoin_asset_id, + ) + .unwrap(), address, blinding_sk, collateral_amount, @@ -294,16 +286,12 @@ async fn lend_and_dynamic_liquidate() { Borrower0::new( &mut rng, - { - let wallet = &mut wallet; - |amount, asset| async move { - wallet.coin_select( - // we need extra coins to pay for the fee - amount + Amount::from_sat(10_000), - asset, - ) - } - }, + wallet + .coin_select( + collateral_amount + Amount::from_sat(10_000), + bitcoin_asset_id, + ) + .unwrap(), address, blinding_sk, collateral_amount, @@ -537,16 +525,13 @@ async fn can_run_protocol_with_principal_change_outputs() { Borrower0::new( &mut rng, - { - let wallet = &mut wallet; - |amount, asset| async move { - wallet.coin_select( - // we need extra coins to pay for the fee - amount + Amount::from_sat(10_000), - asset, - ) - } - }, + wallet + .coin_select( + // we need extra coins to pay for the fee + collateral_amount + Amount::from_sat(10_000), + bitcoin_asset_id, + ) + .unwrap(), address, blinding_sk, collateral_amount,