diff --git a/Cargo.lock b/Cargo.lock index 02c9c4dd2..b4985993d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bdk_coin_select" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0320167c3655e83f0415d52f39618902e449186ffc7dfb090f922f79675c316" + [[package]] name = "bdk_esplora" version = "0.3.0" @@ -230,7 +236,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9532c632b068e45a478f5e309126b6e2ec1dbf0bbd327b73836f33d9a43ede" dependencies = [ - "bitcoin 0.30.1", + "bitcoin 0.30.2", "percent-encoding-rfc3986", ] @@ -260,9 +266,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.30.1" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e99ff7289b20a7385f66a0feda78af2fc119d28fb56aea8886a9cd0a4abdd75" +checksum = "1945a5048598e4189e239d3f809b19bdad4845c4b2ba400d304d2dcf26d2c462" dependencies = [ "base64 0.13.1", "bech32", @@ -519,6 +525,57 @@ dependencies = [ "subtle", ] +[[package]] +name = "dlc" +version = "0.4.0" +source = "git+https://github.com/benthecarman/rust-dlc?branch=mutiny#de64c4d604544eb5b310ec60b6f0be83c523e9fa" +dependencies = [ + "bitcoin 0.29.2", + "miniscript", + "secp256k1-sys 0.6.1", + "secp256k1-zkp", + "serde", +] + +[[package]] +name = "dlc-manager" +version = "0.4.0" +source = "git+https://github.com/benthecarman/rust-dlc?branch=mutiny#de64c4d604544eb5b310ec60b6f0be83c523e9fa" +dependencies = [ + "async-trait", + "bitcoin 0.29.2", + "dlc", + "dlc-messages", + "dlc-trie", + "lightning", + "log", + "secp256k1-zkp", + "serde", +] + +[[package]] +name = "dlc-messages" +version = "0.4.0" +source = "git+https://github.com/benthecarman/rust-dlc?branch=mutiny#de64c4d604544eb5b310ec60b6f0be83c523e9fa" +dependencies = [ + "bitcoin 0.29.2", + "dlc", + "lightning", + "secp256k1-zkp", + "serde", +] + +[[package]] +name = "dlc-trie" +version = "0.4.0" +source = "git+https://github.com/benthecarman/rust-dlc?branch=mutiny#de64c4d604544eb5b310ec60b6f0be83c523e9fa" +dependencies = [ + "bitcoin 0.29.2", + "dlc", + "secp256k1-zkp", + "serde", +] + [[package]] name = "downcast" version = "0.11.0" @@ -549,11 +606,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c18ee0ed65a5f1f81cac6b1d213b69c35fa47d4252ad41f1486dbd8226fe36e" +checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" dependencies = [ "libc", "windows-sys", @@ -723,9 +786,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.10" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" dependencies = [ "cfg-if", "js-sys", @@ -797,9 +860,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.21" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" +checksum = "4d6250322ef6e60f93f9a2162799302cd6f68f79f6e5d85c8c16f14d1d958178" dependencies = [ "bytes", "fnv", @@ -822,9 +885,9 @@ checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" [[package]] name = "hashbrown" -version = "0.12.3" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" [[package]] name = "hermit-abi" @@ -849,9 +912,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.9" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" dependencies = [ "bytes", "fnv", @@ -953,11 +1016,11 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.3" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" dependencies = [ - "autocfg", + "equivalent", "hashbrown", ] @@ -1090,9 +1153,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" +checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" [[package]] name = "lnurl-rs" @@ -1210,12 +1273,17 @@ dependencies = [ "bdk", "bdk-macros", "bdk_chain", + "bdk_coin_select", "bdk_esplora", "bip39", "bitcoin 0.29.2", "cbc", "cfg-if", "chrono", + "dlc", + "dlc-manager", + "dlc-messages", + "dlc-trie", "esplora-client", "futures", "futures-util", @@ -1316,7 +1384,7 @@ dependencies = [ "aes", "base64 0.21.5", "bip39", - "bitcoin 0.30.1", + "bitcoin 0.30.2", "cbc", "chacha20", "getrandom", @@ -1473,7 +1541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac532e6caa3a192dd6017a88446c2a1014d31b66cc68f04c584a846a4cb0373" dependencies = [ "bip21", - "bitcoin 0.30.1", + "bitcoin 0.30.2", "log", "url", ] @@ -1769,9 +1837,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.21" +version = "0.38.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234" dependencies = [ "bitflags 2.4.1", "errno", @@ -1782,9 +1850,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.21.8" +version = "0.21.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446e14c5cda4f3f30fe71863c34ec70f5ac79d6087097ad0bb433e1be5edf04c" +checksum = "629648aced5775d558af50b2b4c7b02983a04b312126d45eeead26e7caa498b9" dependencies = [ "log", "ring", @@ -1893,6 +1961,28 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-zkp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd403e9f0569b4131ab3fc9fa24a17775331b39382efd2cde851fdca655e3520" +dependencies = [ + "rand", + "secp256k1 0.24.3", + "secp256k1-zkp-sys", + "serde", +] + +[[package]] +name = "secp256k1-zkp-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e7a2beac087c1da2d21018a3b7f043fe2f138654ad9c1518d409061a4a0034" +dependencies = [ + "cc", + "secp256k1-sys 0.6.1", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -2004,9 +2094,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" +checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" [[package]] name = "socket2" @@ -2139,9 +2229,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.33.0" +version = "1.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" +checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" dependencies = [ "backtrace", "bytes", @@ -2156,9 +2246,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", @@ -2673,9 +2763,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" dependencies = [ "zeroize_derive", ] diff --git a/mutiny-core/Cargo.toml b/mutiny-core/Cargo.toml index eb1a177d1..18928de86 100644 --- a/mutiny-core/Cargo.toml +++ b/mutiny-core/Cargo.toml @@ -20,6 +20,7 @@ bitcoin = { version = "0.29.2", default-features = false, features = ["std", "se bdk = { version = "=1.0.0-alpha.1" } bdk_esplora = { version = "=0.3.0", default-features = false, features = ["std", "async-https"] } bdk_chain = { version = "=0.5.0", features = ["std"] } +bdk_coin_select = "0.1.1" bdk-macros = "0.6.0" getrandom = { version = "0.2" } itertools = "0.11.0" @@ -55,6 +56,11 @@ anyhow = "1.0" # explict dep due to nightly - https://github.com/rust-lang/rust/issues/113152 proc-macro2 = "1.0.64" +dlc = { git = "https://github.com/benthecarman/rust-dlc", branch = "mutiny", features = ["use-serde"] } +dlc-manager = { git = "https://github.com/benthecarman/rust-dlc", branch = "mutiny", features = ["use-serde"] } +dlc-messages = { git = "https://github.com/benthecarman/rust-dlc", branch = "mutiny", features = [ "use-serde"] } +dlc-trie = { git = "https://github.com/benthecarman/rust-dlc", branch = "mutiny", features = ["use-serde"] } + [dev-dependencies] wasm-bindgen-test = "0.3.33" mockall = "0.11.2" diff --git a/mutiny-core/src/dlc/mod.rs b/mutiny-core/src/dlc/mod.rs new file mode 100644 index 000000000..7fd0f6747 --- /dev/null +++ b/mutiny-core/src/dlc/mod.rs @@ -0,0 +1,372 @@ +use crate::dlc::storage::DlcStorage; +use crate::error::MutinyError; +use crate::fees::MutinyFeeEstimator; +use crate::logging::MutinyLogger; +use crate::onchain::OnChainWallet; +use crate::storage::MutinyStorage; +use crate::utils; +use bdk::wallet::AddressIndex; +use bdk::SignOptions; +use bdk_chain::ConfirmationTime; +use bdk_coin_select::{ + Candidate, CoinSelector, Drain, FeeRate, Target, TR_KEYSPEND_SATISFACTION_WEIGHT, + TXIN_BASE_WEIGHT, +}; +use bitcoin::psbt::PartiallySignedTransaction; +use bitcoin::secp256k1::{All, PublicKey, Secp256k1, SecretKey}; +use bitcoin::util::bip32::ChildNumber; +use bitcoin::{Address, Block, Network, Script, Transaction, Txid, XOnlyPublicKey}; +use dlc_manager::error::Error; +use dlc_manager::{Oracle, Signer, Time, Utxo}; +use dlc_messages::oracle_msgs::{OracleAnnouncement, OracleAttestation}; +use futures_util::lock::Mutex; +use lightning::log_error; +use lightning::util::logger::Logger; +use std::collections::HashMap; +use std::sync::Arc; + +mod storage; + +pub use storage::{DLC_CONTRACT_KEY_PREFIX, DLC_KEY_INDEX_KEY}; + +const TR_INPUT_WEIGHT: u32 = TXIN_BASE_WEIGHT + TR_KEYSPEND_SATISFACTION_WEIGHT; + +pub type DlcManager = dlc_manager::manager::Manager< + Arc>, + Arc>, + Arc>, + Arc, + Arc, + Arc>, +>; + +#[derive(Clone)] +pub struct DlcHandler { + pub manager: Arc>>, + pub logger: Arc, +} + +impl DlcHandler { + pub fn new( + wallet: Arc>, + logger: Arc, + ) -> Result { + let store = Arc::new(DlcStorage::new(wallet.storage.clone())); + + let dlc_wallet = DlcWallet { + wallet: wallet.clone(), + storage: store.clone(), + logger: logger.clone(), + secp: Secp256k1::new(), + }; + + let manager = DlcManager::new( + Arc::new(dlc_wallet), + Arc::new(DlcBlockchain(wallet.clone())), + store, + HashMap::new(), + Arc::new(MutinyTimeProvider {}), + wallet.fees.clone(), + ) + .map_err(|e| anyhow::anyhow!("Failed to create dlc manager: {e}"))?; + + Ok(Self { + manager: Arc::new(Mutex::new(manager)), + logger, + }) + } +} + +fn bdk_err_to_manager_err(e: bdk::Error) -> Error { + create_wallet_error(&format!("{:?}", e)) +} + +fn create_wallet_error(error: &str) -> Error { + Error::WalletError(Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + error, + ))) +} + +/// A wrapper around a bdk wallet that implements the different traits needed by the dlc manager +#[derive(Clone)] +pub struct DlcWallet { + pub wallet: Arc>, + pub storage: Arc>, + pub logger: Arc, + pub secp: Secp256k1, +} + +impl DlcWallet { + pub fn get_secret_key_for_index(&self, index: u32) -> SecretKey { + let network_index = if self.wallet.network == Network::Bitcoin { + ChildNumber::from_hardened_idx(0).expect("infallible") + } else { + ChildNumber::from_hardened_idx(1).expect("infallible") + }; + + let path = [ + ChildNumber::from_hardened_idx(586).expect("infallible"), + network_index, + ChildNumber::from_hardened_idx(index).unwrap(), + ]; + + self.wallet + .xprivkey + .derive_priv(&self.secp, &path) + .unwrap() + .private_key + } +} + +impl Signer for DlcWallet { + fn sign_psbt_input( + &self, + psbt: &mut PartiallySignedTransaction, + input_index: usize, + ) -> Result<(), Error> { + let Ok(wallet) = self.wallet.wallet.try_read() else { + log_error!(self.logger, "Could not get wallet lock to sign tx input"); + return Err(create_wallet_error( + "Failed to get wallet lock to sign tx input", + )); + }; + + let sig_options = SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + + let mut to_sign = psbt.clone(); + wallet.sign(&mut to_sign, sig_options).map_err(|e| { + log_error!(self.logger, "Failed to sign tx input: {e:?}"); + bdk_err_to_manager_err(e) + })?; + + // Since we can only sign the whole PSBT, we need to just copy over + // the one input we signed. + // https://github.com/bitcoindevkit/bdk/issues/1219 + psbt.inputs[input_index] = to_sign.inputs[input_index].clone(); + + Ok(()) + } + + fn get_secret_key_for_pubkey(&self, pk: &PublicKey) -> Result { + let index = self + .storage + .get_index_for_key(pk) + .map_err(|e| Error::WalletError(Box::new(e)))?; + + Ok(self.get_secret_key_for_index(index)) + } +} + +impl dlc_manager::Wallet for DlcWallet { + fn get_new_address(&self) -> Result { + let Ok(mut wallet) = self.wallet.wallet.try_write() else { + log_error!(self.logger, "Could not get wallet lock to get new address"); + return Err(create_wallet_error( + "Failed to get wallet lock to get new address", + )); + }; + + let address = wallet.get_address(AddressIndex::New).address; + Ok(address) + } + + fn get_new_secret_key(&self) -> Result { + let index = self.storage.get_next_key_index(); + let key = self.get_secret_key_for_index(index); + let pk = PublicKey::from_secret_key(&self.secp, &key); + self.storage + .add_new_key(pk, index) + .map_err(|e| Error::WalletError(Box::new(e)))?; + + Ok(key) + } + + fn get_utxos_for_amount( + &self, + amount: u64, + fee_rate: Option, + _lock_utxos: bool, + ) -> Result, Error> { + let utxos = self + .wallet + .list_utxos() + .map_err(|e| Error::WalletError(Box::new(e)))? + .into_iter() + // only use confirmed utxos + .filter(|u| matches!(u.confirmation_time, ConfirmationTime::Confirmed { .. })) + .collect::>(); + + let candidates = utxos + .iter() + .map(|u| Candidate { + input_count: 1, + value: u.txout.value, + weight: TR_INPUT_WEIGHT, + is_segwit: true, + }) + .collect::>(); + + let target = Target { + feerate: FeeRate::from_sat_per_vb(fee_rate.unwrap_or(1) as f32), + min_fee: 0, + value: amount, + }; + + // base weight of 212 is standard for DLC transaction + let mut coin_selector = CoinSelector::new(&candidates, 212); + coin_selector + .select_until_target_met(target, Drain::none()) + .map_err(|e| { + log_error!(self.logger, "Failed to select coins: {e:?}"); + Error::WalletError(Box::new(e)) + })?; + + // Check that selection is finished! + debug_assert!(coin_selector.is_target_met(target, Drain::none())); + + // Get a list of coins that are selected. + let selected_coins = coin_selector + .apply_selection(&candidates) + .collect::>(); + + let mut selection: Vec = Vec::with_capacity(selected_coins.len()); + for coin in selected_coins { + let Some(utxo) = utxos.iter().find(|u| { + // same value and not already selected + u.txout.value == coin.value && !selection.iter().any(|s| s.outpoint == u.outpoint) + }) else { + return Err(create_wallet_error("Could not find utxo for coin")); + }; + + let address = + Address::from_script(&utxo.txout.script_pubkey, self.wallet.network).unwrap(); + let u = Utxo { + tx_out: utxo.txout.clone(), + outpoint: utxo.outpoint, + address, + redeem_script: Script::new(), + reserved: false, + }; + + selection.push(u); + } + + Ok(selection) + } + + fn import_address(&self, _address: &Address) -> Result<(), Error> { + // BDK does not support importing addresses which is fine. + // We will always see the funding tx spending our funds and we will be able to track the + // closing tx as well. + Ok(()) + } +} + +pub struct MutinyTimeProvider {} +impl Time for MutinyTimeProvider { + fn unix_time_now(&self) -> u64 { + utils::now().as_secs() + } +} + +pub struct DlcBlockchain(Arc>); + +impl dlc_manager::Blockchain for DlcBlockchain { + fn send_transaction(&self, transaction: &Transaction) -> Result<(), Error> { + let tx = transaction.clone(); + let wallet = self.0.clone(); + utils::spawn(async move { + if let Err(e) = wallet.broadcast_transaction(tx).await { + log_error!(wallet.logger, "Failed to broadcast transaction: {e}"); + } + }); + Ok(()) + } + + fn get_network(&self) -> Result { + Ok(self.0.network) + } + + fn get_blockchain_height(&self) -> Result { + let Ok(wallet) = self.0.wallet.try_read() else { + log_error!( + self.0.logger, + "Could not get wallet lock to get blockchain height" + ); + return Err(create_wallet_error( + "Failed to get wallet lock to get blockchain height", + )); + }; + + Ok(wallet + .latest_checkpoint() + .map(|c| c.height as u64) + .unwrap_or(0)) // if no checkpoint, then we assume 0 + } + + fn get_block_at_height(&self, _: u64) -> Result { + unimplemented!("Only needed for channels") + } + + fn get_transaction(&self, tx_id: &Txid) -> Result { + let Ok(wallet) = self.0.wallet.try_read() else { + log_error!( + self.0.logger, + "Could not get wallet lock to get transaction" + ); + return Err(create_wallet_error( + "Failed to get wallet lock to get transaction", + )); + }; + + Ok(wallet.get_tx(*tx_id, true).unwrap().transaction.unwrap()) + } + + fn get_transaction_confirmations(&self, tx_id: &Txid) -> Result { + let Ok(wallet) = self.0.wallet.try_read() else { + log_error!( + self.0.logger, + "Could not get wallet lock to get tx confirmations" + ); + return Err(create_wallet_error( + "Failed to get wallet lock to get tx confirmations", + )); + }; + + let Some(tx) = wallet.get_tx(*tx_id, true) else { + return Ok(0); + }; + + match tx.confirmation_time { + ConfirmationTime::Confirmed { height, .. } => { + let cur = wallet + .latest_checkpoint() + .map(|c| c.height) + .ok_or(create_wallet_error("Failed to get latest checkpoint"))?; + + Ok(cur.saturating_sub(height) + 1) + } + ConfirmationTime::Unconfirmed { .. } => Ok(0), + } + } +} + +pub struct DummyOracleClient {} + +impl Oracle for DummyOracleClient { + fn get_public_key(&self) -> XOnlyPublicKey { + unimplemented!("Unused") + } + + fn get_announcement(&self, _: &str) -> Result { + unimplemented!("Unused") + } + + fn get_attestation(&self, _: &str) -> Result { + unimplemented!("Unused") + } +} diff --git a/mutiny-core/src/dlc/storage.rs b/mutiny-core/src/dlc/storage.rs new file mode 100644 index 000000000..6480ae7a0 --- /dev/null +++ b/mutiny-core/src/dlc/storage.rs @@ -0,0 +1,377 @@ +use crate::error::MutinyError; +use crate::storage::{MutinyStorage, VersionedValue}; +use bitcoin::consensus::ReadExt; +use bitcoin::hashes::hex::ToHex; +use bitcoin::secp256k1::PublicKey; +use dlc_manager::chain_monitor::ChainMonitor; +use dlc_manager::channel::offered_channel::OfferedChannel; +use dlc_manager::channel::signed_channel::{SignedChannel, SignedChannelStateType}; +use dlc_manager::channel::Channel; +use dlc_manager::contract::accepted_contract::AcceptedContract; +use dlc_manager::contract::offered_contract::OfferedContract; +use dlc_manager::contract::ser::Serializable; +use dlc_manager::contract::signed_contract::SignedContract; +use dlc_manager::contract::{ + ClosedContract, Contract, FailedAcceptContract, FailedSignContract, PreClosedContract, +}; +use dlc_manager::ChannelId; +use dlc_manager::{error::Error, ContractId}; +use lightning::io::Cursor; +use serde_json::Value; +use std::collections::HashMap; +use std::convert::TryInto; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +macro_rules! convertible_enum { + (enum $name:ident { + $($vname:ident $(= $val:expr)?,)*; + $($tname:ident $(= $tval:expr)?,)* + }, $input:ident) => { + #[derive(Debug)] + enum $name { + $($vname $(= $val)?,)* + $($tname $(= $tval)?,)* + } + + impl From<$name> for u8 { + fn from(prefix: $name) -> u8 { + prefix as u8 + } + } + + impl std::convert::TryFrom for $name { + type Error = Error; + + fn try_from(v: u8) -> Result { + match v { + $(x if x == u8::from($name::$vname) => Ok($name::$vname),)* + $(x if x == u8::from($name::$tname) => Ok($name::$tname),)* + _ => Err(Error::StorageError("Unknown prefix".to_string())), + } + } + } + + impl $name { + #[allow(dead_code)] + fn get_prefix(input: &$input) -> u8 { + let prefix = match input { + $($input::$vname(_) => $name::$vname,)* + $($input::$tname{..} => $name::$tname,)* + }; + prefix.into() + } + } + } +} + +convertible_enum!( + enum ContractPrefix { + Offered = 1, + Accepted, + Signed, + Confirmed, + PreClosed, + Closed, + FailedAccept, + FailedSign, + Refunded, + Rejected,; + }, + Contract +); + +fn to_storage_error(e: T) -> Error +where + T: std::fmt::Display, +{ + Error::StorageError(e.to_string()) +} + +pub const DLC_CONTRACT_KEY_PREFIX: &str = "dlc_contract/"; +pub const DLC_KEY_INDEX_KEY: &str = "dlc_key_index"; + +#[derive(Clone)] +pub struct DlcStorage { + pub(crate) storage: S, + key_index_counter: Arc, +} + +impl DlcStorage { + pub fn new(storage: S) -> Self { + Self { + storage, + key_index_counter: Arc::new(AtomicU32::new(0)), + } + } + + /// Get the next key index to use for a new contract. Saves the index to storage. + /// This is used to generate unique keys for contracts + pub(crate) fn get_next_key_index(&self) -> u32 { + self.key_index_counter.fetch_add(1, Ordering::SeqCst) + } + + pub(crate) fn add_new_key(&self, pk: PublicKey, index: u32) -> Result<(), MutinyError> { + let mut current: HashMap = + match self.storage.get_data::(DLC_KEY_INDEX_KEY)? { + Some(value) => value.get_value()?, + None => HashMap::with_capacity(1), + }; + + current.insert(pk, index); + + // Save the new key index map and set the version to the current index + // this way it is stored in VSS with the latest version + let value = VersionedValue { + value: serde_json::to_value(current)?, + version: index, + }; + self.storage + .set_data(DLC_KEY_INDEX_KEY, value, Some(index))?; + + Ok(()) + } + + pub(crate) fn get_index_for_key(&self, pk: &PublicKey) -> Result { + let current: HashMap = + match self.storage.get_data::(DLC_KEY_INDEX_KEY)? { + Some(value) => value.get_value()?, + None => return Err(MutinyError::NotFound), + }; + current.get(pk).copied().ok_or(MutinyError::NotFound) + } +} + +impl dlc_manager::Storage for DlcStorage { + fn get_contract(&self, id: &ContractId) -> Result, Error> { + let key = format!("{DLC_CONTRACT_KEY_PREFIX}{}", id.to_hex()); + match self + .storage + .get_data::(&key) + .map_err(to_storage_error)? + { + None => Ok(None), + Some(value) => { + let string: String = value.get_value().map_err(to_storage_error)?; + let bytes: Vec = base64::decode(string).map_err(to_storage_error)?; + Ok(Some(deserialize_contract(&bytes)?)) + } + } + } + + fn get_contracts(&self) -> Result, Error> { + self.storage + .scan::(DLC_CONTRACT_KEY_PREFIX, None) + .map_err(to_storage_error)? + .into_values() + .map(|value| { + let string: String = value.get_value().map_err(to_storage_error)?; + base64::decode(string) + .map_err(to_storage_error) + .and_then(|b| deserialize_contract(&b)) + }) + .collect() + } + + fn create_contract(&self, contract: &OfferedContract) -> Result<(), Error> { + let serialized = serialize_contract(&Contract::Offered(contract.clone()))?; + let key = format!("{DLC_CONTRACT_KEY_PREFIX}{}", contract.id.to_hex()); + + let value = VersionedValue { + value: Value::String(base64::encode(serialized)), + version: 0, + }; + + self.storage + .set_data(key, value, None) + .map_err(to_storage_error) + } + + fn delete_contract(&self, id: &ContractId) -> Result<(), Error> { + let key = format!("{DLC_CONTRACT_KEY_PREFIX}{}", id.to_hex()); + self.storage.delete(&[key]).map_err(to_storage_error) + } + + fn update_contract(&self, contract: &Contract) -> Result<(), Error> { + let serialized = serialize_contract(contract)?; + let key = format!("{DLC_CONTRACT_KEY_PREFIX}{}", contract.get_id().to_hex()); + + let version = get_version(contract); + let value = VersionedValue { + value: Value::String(base64::encode(serialized)), + version: version.unwrap_or(0), + }; + + self.storage + .set_data(key, value, version) + .map_err(to_storage_error)?; + + // if the contract was in the offer state, we can delete the version with the temporary id + match contract { + a @ Contract::Accepted(_) | a @ Contract::Signed(_) => { + let key = format!("{DLC_CONTRACT_KEY_PREFIX}{}", a.get_temporary_id().to_hex()); + self.storage.delete(&[key]).map_err(to_storage_error)?; + } + _ => {} + }; + + Ok(()) + } + + fn get_contract_offers(&self) -> Result, Error> { + Ok(self + .get_contracts()? + .into_iter() + .filter_map(|c| match c { + Contract::Offered(o) => Some(o), + _ => None, + }) + .collect()) + } + + fn get_signed_contracts(&self) -> Result, Error> { + Ok(self + .get_contracts()? + .into_iter() + .filter_map(|c| match c { + Contract::Signed(o) => Some(o), + _ => None, + }) + .collect()) + } + + fn get_confirmed_contracts(&self) -> Result, Error> { + Ok(self + .get_contracts()? + .into_iter() + .filter_map(|c| match c { + Contract::Confirmed(o) => Some(o), + _ => None, + }) + .collect()) + } + + fn get_preclosed_contracts(&self) -> Result, Error> { + Ok(self + .get_contracts()? + .into_iter() + .filter_map(|c| match c { + Contract::PreClosed(o) => Some(o), + _ => None, + }) + .collect()) + } + + fn upsert_channel(&self, _: Channel, _: Option) -> Result<(), Error> { + Ok(()) // Channels not supported + } + + fn delete_channel(&self, _: &ChannelId) -> Result<(), Error> { + Ok(()) // Channels not supported + } + + fn get_channel(&self, _: &ChannelId) -> Result, Error> { + Ok(None) // Channels not supported + } + + fn get_signed_channels( + &self, + _: Option, + ) -> Result, Error> { + Ok(vec![]) // Channels not supported + } + + fn get_offered_channels(&self) -> Result, Error> { + Ok(vec![]) // Channels not supported + } + + fn persist_chain_monitor(&self, _: &ChainMonitor) -> Result<(), Error> { + Ok(()) // Channels not supported + } + + fn get_chain_monitor(&self) -> Result, Error> { + Ok(None) // Channels not supported + } +} + +fn get_version(contract: &Contract) -> Option { + match contract { + Contract::Offered(_) => None, + Contract::Accepted(_) => Some(1), + Contract::Signed(_) => Some(2), + Contract::Confirmed(_) => Some(3), + Contract::PreClosed(_) => Some(4), + Contract::Closed(_) => Some(5), + Contract::Refunded(_) => Some(5), + Contract::FailedAccept(_) => None, + Contract::FailedSign(_) => Some(2), + Contract::Rejected(_) => None, + } +} + +fn serialize_contract(contract: &Contract) -> Result, lightning::io::Error> { + let mut serialized = match contract { + Contract::Offered(o) | Contract::Rejected(o) => o.serialize(), + Contract::Accepted(o) => o.serialize(), + Contract::Signed(o) | Contract::Confirmed(o) | Contract::Refunded(o) => o.serialize(), + Contract::FailedAccept(c) => c.serialize(), + Contract::FailedSign(c) => c.serialize(), + Contract::PreClosed(c) => c.serialize(), + Contract::Closed(c) => c.serialize(), + }?; + let mut res = Vec::with_capacity(serialized.len() + 1); + res.push(ContractPrefix::get_prefix(contract)); + res.append(&mut serialized); + Ok(res) +} + +fn deserialize_contract(buff: &Vec) -> Result { + let mut cursor = Cursor::new(buff); + let prefix = cursor.read_u8().map_err(to_storage_error)?; + let contract_prefix: ContractPrefix = prefix.try_into()?; + let contract = match contract_prefix { + ContractPrefix::Offered => { + Contract::Offered(OfferedContract::deserialize(&mut cursor).map_err(to_storage_error)?) + } + ContractPrefix::Accepted => Contract::Accepted( + AcceptedContract::deserialize(&mut cursor).map_err(to_storage_error)?, + ), + ContractPrefix::Signed => { + Contract::Signed(SignedContract::deserialize(&mut cursor).map_err(to_storage_error)?) + } + ContractPrefix::Confirmed => { + Contract::Confirmed(SignedContract::deserialize(&mut cursor).map_err(to_storage_error)?) + } + ContractPrefix::PreClosed => Contract::PreClosed( + PreClosedContract::deserialize(&mut cursor).map_err(to_storage_error)?, + ), + ContractPrefix::Closed => { + Contract::Closed(ClosedContract::deserialize(&mut cursor).map_err(to_storage_error)?) + } + ContractPrefix::FailedAccept => Contract::FailedAccept( + FailedAcceptContract::deserialize(&mut cursor).map_err(to_storage_error)?, + ), + ContractPrefix::FailedSign => Contract::FailedSign( + FailedSignContract::deserialize(&mut cursor).map_err(to_storage_error)?, + ), + ContractPrefix::Refunded => { + Contract::Refunded(SignedContract::deserialize(&mut cursor).map_err(to_storage_error)?) + } + ContractPrefix::Rejected => { + Contract::Rejected(OfferedContract::deserialize(&mut cursor).map_err(to_storage_error)?) + } + }; + Ok(contract) +} + +#[cfg(test)] +mod test { + const CONTRACT: &str = "AQ68ldR1g4+lMEK8Thnn6YVBQSPCmMSDE3yhTr94i6aYAQEAAwVIZWFkcwAAAAAAAE4gAAAAAAAAAAAFVGFpbHMAAAAAAAAAAAAAAAAAAE4gBU90aGVyAAAAAAAATiAAAAAAAAAAAAH+fbKa07eOeLmFEq7s+oEqLvvEezDGts8b+nde8ioDPiv/AAmOFo7qr+02HLDDWRTfbSGG+EbV2SUopvkHf28A6BSt/rJCA1591v1sZk7s0jYALK6l+FfRcP0z89JxR6X92CJWAAGlLn5AA0hFODKxAnrWjypuhXUbvz9Zfou91hPgK3LcB2Owp4D92AYUAAMFSGVhZHMFVGFpbHMFT3RoZXIXQ29pbiBGbGlwOiBwcm9tcHRfaWQ6IDMAAAAAAAAAAQINsC8f/fzu4kZIQ3fRBsLcMQgYKR37cUg0DuSghIszUAAiUSAVRsZLDbs7nb3h0iD1dJq1hJSb/75ai1bcBsnjiMoB7AH4SBYY6DbDACJRIB+HX+NTITKZXVG8cwyw/msjOzQevUrfsvK/MCxSJGXGQc9CZTYW+vgCLCil9Bsdm2wLqWMjeS/0njJx9b1u2jwd9FR0Q4h8cEMAAAABAAAAAAAAAGsAANFyTIr3AbIwfl/BWtUZ7ggHvQYTdfpsBKzXZ90VekKxdvq1Pl2UIBMAAAABAAAAAAAAAGsAAMu8nsx8bof0AAAAAAANujEAAAAAAAAnEAAAAAAAAE4gAtFyTIr3AbIw/QFlAgAAAAABAX5fwVrVGe4IB70GE3X6bASs12fdFXpCsXb6tT5dlCATAAAAAAD/////AqGGAQAAAAAAIlEggwVyU+kC22wCN614y2wCtnTvuF/Ln92cptOwzBl2j4gwNAwAAAAAACJRIPzpzEQqLkazw4rRnglUQIk+dpmmshqGTfDyc5yk5fvDBABHMEQCIGyaSYj/vfO7ODRKV/xulr3Ee58R0i1+OufMlxUa+njcAiA8OTf/Feg/IIlnA4qEtFGdm7jksIZrOTijVWBjKTFv8QFHMEQCIBvNV6dMdh9SlT5XglwK66hnod7zopHT4RVkVhfx0AHnAiAdc2mk3uMEyfMN805E4jnSN2PHywLpqihWvwTFMc6EKQFHUiECMA2WZ/AHrCQrohTqaAwu6aWJhUAfUzgDRLDlKlDyzOEhAuwZwjj5yAd/DKBptU26oE4WjHZX9wBrpKwVHyT0U7SgUq4AAAAAAAAAAf////8AawAAAQAiUSD86cxEKi5Gs8OK0Z4JVECJPnaZprIahk3w8nOcpOX7wwLLvJ7MfG6H9M0BAAAAAAEBeeE5jZDiWmQr024298DGmNniTu2umnSNjqmgMDbq5yABAAAAAP3///8CoLsNAAAAAAAiACAgWddflXslHvYkNG0voUEqe8u5S/YMpI9Ub1TTzF2RwgGGAQAAAAAAIlEgL13S33W6Alqltjr+uyrWxPdpLFGRVIhcrFzMHz0KelMBQE7cNkv7asK8qIEtMTZHbxPdpmUAuYQcygWYmquLYJkFZs5f6mJslvsiNHjEteW9yyBbgZPmoPYvM9puFHLJ4kmMGQgAAAAAAf////8AawAAAQAiUSAvXdLfdboCWqW2Ov67KtbE92ksUZFUiFysXMwfPQp6UwJp7uXSKrCglwAAAAAAAAACZVLPy2O54gACgdG50ZdBvVN5GiSylSn5wqojFmEVMlhgFBqgcRBTLXU="; + + #[test] + fn test_parse_contract() { + let bytes = base64::decode(CONTRACT).unwrap(); + let contract = super::deserialize_contract(&bytes).unwrap(); + assert!(matches!(contract, super::Contract::Offered(_))); + } +} diff --git a/mutiny-core/src/error.rs b/mutiny-core/src/error.rs index 65b5e4bfd..4a0717b2a 100644 --- a/mutiny-core/src/error.rs +++ b/mutiny-core/src/error.rs @@ -467,3 +467,15 @@ impl From for MutinyError { Self::PayjoinValidateResponse(e) } } + +impl From for MutinyError { + fn from(_e: dlc::Error) -> Self { + Self::DLCManagerError + } +} + +impl From for MutinyError { + fn from(_e: dlc_manager::error::Error) -> Self { + Self::DLCManagerError + } +} diff --git a/mutiny-core/src/lib.rs b/mutiny-core/src/lib.rs index 460a1b38d..812603fa2 100644 --- a/mutiny-core/src/lib.rs +++ b/mutiny-core/src/lib.rs @@ -14,6 +14,7 @@ mod background; pub mod auth; mod chain; +mod dlc; pub mod encrypt; pub mod error; pub mod esplora; @@ -43,12 +44,15 @@ pub mod vss; #[cfg(test)] mod test_utils; +pub use crate::dlc::{DLC_CONTRACT_KEY_PREFIX, DLC_KEY_INDEX_KEY}; pub use crate::event::HTLCStatus; pub use crate::gossip::{GOSSIP_SYNC_TIME_KEY, NETWORK_GRAPH_KEY, PROB_SCORER_KEY}; pub use crate::keymanager::generate_seed; pub use crate::ldkstorage::{CHANNEL_MANAGER_KEY, MONITORS_PREFIX_KEY}; +pub use dlc_messages::oracle_msgs::{OracleAnnouncement, OracleAttestation}; use crate::auth::MutinyAuthClient; +use crate::dlc::*; use crate::labels::{get_contact_key, Contact, LabelStorage}; use crate::nostr::nwc::{ BudgetPeriod, BudgetedSpendingConditions, NwcProfileTag, SpendingConditions, @@ -59,11 +63,17 @@ use crate::{error::MutinyError, nostr::ReservedProfile}; use crate::{nodemanager::NodeManager, nostr::ProfileType}; use crate::{nostr::NostrManager, utils::sleep}; use ::nostr::key::XOnlyPublicKey; +use ::nostr::secp256k1::Parity; use ::nostr::{Event, Kind, Metadata}; use bip39::Mnemonic; use bitcoin::secp256k1::PublicKey; use bitcoin::util::bip32::ExtendedPrivKey; use bitcoin::Network; +use dlc_manager::contract::contract_input::{ContractInput, ContractInputInfo, OracleInput}; +use dlc_manager::contract::enum_descriptor::EnumDescriptor; +use dlc_manager::contract::{Contract, ContractDescriptor}; +use dlc_manager::{ContractId, Storage}; +use dlc_messages::Message; use futures::{pin_mut, select, FutureExt}; use lightning::{log_debug, util::logger::Logger}; use lightning::{log_error, log_info, log_warn}; @@ -72,8 +82,58 @@ use nostr_sdk::{Client, RelayPoolNotification}; use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::Ordering; + use std::sync::Arc; +pub use ::dlc as rust_dlc; +pub use ::dlc_manager; +pub use ::dlc_messages; +use dlc_messages::oracle_msgs::EventDescriptor; +use esplora_client::OutputStatus; + +pub fn create_contract_input( + collateral: u64, + descriptor: EnumDescriptor, + announcement: OracleAnnouncement, +) -> Result { + match announcement.oracle_event.event_descriptor { + EventDescriptor::EnumEvent(e) => { + if e.outcomes + != descriptor + .outcome_payouts + .iter() + .map(|x| x.outcome.clone()) + .collect::>() + { + return Err(MutinyError::InvalidArgumentsError); + } + } + EventDescriptor::DigitDecompositionEvent(_) => unimplemented!("digit decomposition"), + } + let contract_info = ContractInputInfo { + contract_descriptor: ContractDescriptor::Enum(descriptor), + oracles: OracleInput { + public_keys: vec![announcement.oracle_public_key], + event_id: announcement.oracle_event.event_id, + threshold: 1, + }, + }; + + let input = ContractInput { + offer_collateral: collateral, + accept_collateral: collateral, + fee_rate: 2, + contract_infos: vec![contract_info], + }; + + input.validate().map_err(|e| { + log::error!("Error validating contract input: {e}"); + MutinyError::DLCManagerError + })?; + + Ok(input) +} + #[derive(Clone)] pub struct MutinyWalletConfig { xprivkey: ExtendedPrivKey, @@ -145,6 +205,7 @@ pub struct MutinyWallet { pub storage: S, pub node_manager: Arc>, pub nostr: Arc>, + pub dlc: Arc>, } impl MutinyWallet { @@ -166,11 +227,59 @@ impl MutinyWallet { let node_manager = Arc::new(NodeManager::new(config.clone(), storage.clone(), session_id).await?); + let dlc = Arc::new(DlcHandler::new( + node_manager.wallet.clone(), + node_manager.logger.clone(), + )?); + NodeManager::start_sync(node_manager.clone()); + // DLC syncing + let esplora = node_manager.esplora.clone(); + let dlc_clone = dlc.clone(); + let stop = node_manager.stop.clone(); + utils::spawn(async move { + loop { + if stop.load(Ordering::Relaxed) { + break; + }; + + let mut dlc = dlc_clone.manager.lock().await; + if let Err(e) = dlc.periodic_check(false) { + log_error!(dlc_clone.logger, "Error checking DLCs: {e:?}"); + } else { + log_info!(dlc_clone.logger, "DLCs synced!"); + } + + // check if any of the contracts have been closed + let to_watch = dlc.outputs_to_watch().unwrap_or_default(); + for (outpoint, contract) in to_watch { + // if it has been spent, find the close tx and process it + if let Ok(Some(OutputStatus { + txid: Some(txid), .. + })) = esplora + .get_output_status(&outpoint.txid, outpoint.vout as u64) + .await + { + if let Ok(Some(tx)) = esplora.get_tx(&txid).await { + // for now just put 6 confirmations + if let Err(e) = dlc.on_counterparty_close(&contract, tx, 6) { + log_error!(dlc_clone.logger, "Error processing close tx: {e:?}"); + } + } + } + } + + drop(dlc); + + sleep(60_000).await; + } + }); + // create nostr manager let nostr = Arc::new(NostrManager::from_mnemonic( node_manager.xprivkey, + dlc.clone(), storage.clone(), node_manager.logger.clone(), )?); @@ -187,6 +296,7 @@ impl MutinyWallet { storage, node_manager, nostr, + dlc, }; #[cfg(not(test))] @@ -213,7 +323,7 @@ impl MutinyWallet { }; // start the nostr wallet connect background process - mw.start_nostr_wallet_connect(first_node).await; + mw.start_nostr(first_node).await; Ok(mw) } @@ -235,8 +345,141 @@ impl MutinyWallet { Ok(()) } - /// Starts a background process that will watch for nostr wallet connect events - pub(crate) async fn start_nostr_wallet_connect(&self, from_node: PublicKey) { + pub async fn send_dlc_offer( + &self, + contract_input: &ContractInput, + oracle_announcement: OracleAnnouncement, + pubkey: XOnlyPublicKey, + ) -> Result { + let mut dlc = self.dlc.manager.lock().await; + let counter_party = PublicKey::from_slice(&pubkey.public_key(Parity::Even).serialize()) + .expect("converting pubkey between crates should not fail"); + let msg = dlc + .send_offer_with_announcements( + contract_input, + counter_party, + vec![vec![oracle_announcement]], + ) + .map_err(|e| { + log_error!(self.node_manager.logger, "Error sending DLC offer: {e}"); + e + })?; + + let client = Client::new(&self.nostr.primary_key); + let relay = self.nostr.dlc_handler.relay.clone(); + #[cfg(target_arch = "wasm32")] + let add_relay_res = client.add_relay(relay).await; + + #[cfg(not(target_arch = "wasm32"))] + let add_relay_res = client.add_relay(relay, None).await; + + add_relay_res.expect("Failed to add relays"); + client.connect().await; + + let contract_id = ContractId::from(msg.temporary_contract_id); + + let event = + self.nostr + .dlc_handler + .create_wire_msg_event(pubkey, None, Message::Offer(msg))?; + client.send_event(event).await?; + + client.disconnect().await?; + + Ok(contract_id) + } + + pub async fn accept_dlc_offer(&self, contract_id: [u8; 32]) -> Result<(), MutinyError> { + let contract_id = ContractId::from(contract_id); + let mut dlc = self.dlc.manager.lock().await; + let (_, pubkey, msg) = dlc.accept_contract_offer(&contract_id)?; + + let client = Client::new(&self.nostr.primary_key); + let relay = self.nostr.dlc_handler.relay.clone(); + #[cfg(target_arch = "wasm32")] + let add_relay_res = client.add_relay(relay).await; + + #[cfg(not(target_arch = "wasm32"))] + let add_relay_res = client.add_relay(relay, None).await; + + add_relay_res.expect("Failed to add relays"); + client.connect().await; + + let xonly = XOnlyPublicKey::from_slice(&pubkey.x_only_public_key().0.serialize()) + .expect("converting pubkey between crates should not fail"); + let event = + self.nostr + .dlc_handler + .create_wire_msg_event(xonly, None, Message::Accept(msg))?; + client.send_event(event).await?; + + client.disconnect().await?; + + Ok(()) + } + + pub async fn reject_dlc_offer(&self, contract_id: [u8; 32]) -> Result<(), MutinyError> { + let contract_id = ContractId::from(contract_id); + let dlc = self.dlc.manager.lock().await; + if let Some(contract) = dlc.get_store().get_contract(&contract_id)? { + // Only delete the contract if it's an offer or failed, + // otherwise we can't reject it without risking losing funds. + + // todo it is unsafe to delete an accepted contract, need to signal failed accept + match contract { + Contract::Offered(_) | Contract::Accepted(_) => { + dlc.get_store().delete_contract(&contract_id)?; + return Ok(()); + } + Contract::FailedAccept(_) | Contract::FailedSign(_) => { + // if we failed to accept or sign, we can delete the contract + dlc.get_store().delete_contract(&contract_id)?; + return Ok(()); + } + _ => { + log_error!( + self.node_manager.logger, + "Cannot reject a contract that is active" + ); + // todo probably want a more explicit error + return Err(MutinyError::DLCManagerError); + } + } + } + + Err(MutinyError::NotFound) + } + + pub async fn close_dlc( + &self, + contract_id: [u8; 32], + attestation: OracleAttestation, + ) -> Result { + let contract_id = ContractId::from(contract_id); + let mut dlc = self.dlc.manager.lock().await; + let contract = dlc + .close_confirmed_contract(&contract_id, vec![(0, attestation)]) + .map_err(|e| { + log_error!(self.node_manager.logger, "Error closing DLC: {e}"); + e + })?; + + Ok(contract) + } + + pub async fn list_dlcs(&self) -> Result, MutinyError> { + let dlc = self.dlc.manager.lock().await; + let mut contracts = dlc.get_store().get_contracts()?; + contracts.sort_by_key(|c| c.get_id()); + Ok(contracts) + } + + pub fn get_dlc_key(&self) -> XOnlyPublicKey { + self.nostr.dlc_handler.public_key() + } + + /// Starts a background process that will watch for nostr wallet connect & dlc events + pub(crate) async fn start_nostr(&self, payment_node: PublicKey) { let nostr = self.nostr.clone(); let nm = self.node_manager.clone(); utils::spawn(async move { @@ -245,14 +488,6 @@ impl MutinyWallet { break; }; - // if we have no relays, then there are no nwc profiles enabled - // wait 10 seconds and see if we do again - let relays = nostr.get_relays(); - if relays.is_empty() { - utils::sleep(10_000).await; - continue; - } - // clear in-active profiles, we used to have disabled and archived profiles // but now we just delete profiles if let Err(e) = nostr.remove_inactive_profiles() { @@ -261,7 +496,10 @@ impl MutinyWallet { // if a single-use profile's payment was successful in the background, // we can safely clear it now - let node = nm.get_node(&from_node).await.expect("failed to get node"); + let node = nm + .get_node(&payment_node) + .await + .expect("failed to get node"); if let Err(e) = nostr.clear_successful_single_use_profiles(&node) { log_warn!(nm.logger, "Failed to clear in-active NWC profiles: {e}"); } @@ -271,8 +509,6 @@ impl MutinyWallet { log_warn!(nm.logger, "Failed to clear expired NWC invoices: {e}"); } - // clear successful single-use profiles - let client = Client::new(&nostr.primary_key); #[cfg(target_arch = "wasm32")] @@ -286,8 +522,13 @@ impl MutinyWallet { add_relay_res.expect("Failed to add relays"); client.connect().await; + // subscribe to NWC events let mut last_filters = nostr.get_nwc_filters(); client.subscribe(last_filters.clone()).await; + // subscribe to DLC wire messages + client + .subscribe(vec![nostr.dlc_handler.create_wire_msg_filter()]) + .await; // handle NWC requests let mut notifications = client.notifications(); @@ -312,17 +553,33 @@ impl MutinyWallet { notification = read_fut => { match notification { Ok(RelayPoolNotification::Event(_url, event)) => { - if event.kind == Kind::WalletConnectRequest && event.verify().is_ok() { - match nostr.handle_nwc_request(event, &nm, &from_node).await { - Ok(Some(event)) => { - if let Err(e) = client.send_event(event).await { - log_warn!(nm.logger, "Error sending NWC event: {e}"); + if event.verify().is_ok() { + match event.kind { + Kind::WalletConnectRequest => { + match nostr.handle_nwc_request(event, &nm, &payment_node).await { + Ok(Some(event)) => { + if let Err(e) = client.send_event(event).await { + log_warn!(nm.logger, "Error sending NWC event: {e}"); + } + } + Ok(None) => {} // no response + Err(e) => { + log_error!(nm.logger, "Error handling NWC request: {e}"); + } } } - Ok(None) => {} // no response - Err(e) => { - log_error!(nm.logger, "Error handling NWC request: {e}"); + Kind::Ephemeral(28_888) => { + match nostr.dlc_handler.handle_dlc_wire_event(event).await { + Err(e) => log_error!(nm.logger, "Error handling DLC wire event: {e}"), + Ok(None) => {}, + Ok(Some(event)) => { + if let Err(e) = client.send_event(event).await { + log_warn!(nm.logger, "Error sending NWC event: {e}"); + } + } + } } + _ => log_warn!(nm.logger, "Received unexpected Nostr event: {event:?}"), } } }, diff --git a/mutiny-core/src/node.rs b/mutiny-core/src/node.rs index abcfdc98e..2a114c749 100644 --- a/mutiny-core/src/node.rs +++ b/mutiny-core/src/node.rs @@ -167,7 +167,7 @@ pub(crate) struct Node { pub fee_estimator: Arc>, network: Network, pub persister: Arc>, - wallet: Arc>, + pub(crate) wallet: Arc>, pub(crate) logger: Arc, pub(crate) lsp_client: Option, pub(crate) sync_lock: Arc>, diff --git a/mutiny-core/src/nodemanager.rs b/mutiny-core/src/nodemanager.rs index 469e45f83..5418f5225 100644 --- a/mutiny-core/src/nodemanager.rs +++ b/mutiny-core/src/nodemanager.rs @@ -528,7 +528,7 @@ pub struct NodeManager { #[cfg(target_arch = "wasm32")] websocket_proxy_addr: String, user_rgs_url: Option, - esplora: Arc, + pub(crate) esplora: Arc, pub(crate) wallet: Arc>, gossip_sync: Arc, scorer: Arc>, @@ -2482,6 +2482,10 @@ impl NodeManager { self.storage .delete(&[GOSSIP_SYNC_TIME_KEY, NETWORK_GRAPH_KEY, PROB_SCORER_KEY])?; + // todo only for testing, don't merge + let keys = self.storage.scan_keys("dlc_contract", None)?; + self.storage.delete(&keys)?; + // shut back down after reading if it was already closed if needs_db_connection { self.storage.clone().stop(); diff --git a/mutiny-core/src/nostr/dlc.rs b/mutiny-core/src/nostr/dlc.rs new file mode 100644 index 000000000..0489c6257 --- /dev/null +++ b/mutiny-core/src/nostr/dlc.rs @@ -0,0 +1,159 @@ +use crate::dlc::DlcHandler; +use crate::error::MutinyError; +use crate::logging::MutinyLogger; +use crate::storage::MutinyStorage; +use bitcoin::hashes::hex::ToHex; +use dlc::secp256k1_zkp::PublicKey; +use dlc_messages::message_handler::read_dlc_message; +use dlc_messages::{Message, WireMessage}; +use lightning::ln::wire::Type; +use lightning::util::logger::Logger; +use lightning::util::ser::{Readable, Writeable}; +use lightning::{log_info, log_warn}; +use nostr::key::{Keys, XOnlyPublicKey}; +use nostr::prelude::{decrypt, encrypt, Parity}; +use nostr::Url; +use nostr::{Event, EventBuilder, EventId, Filter, Kind, Tag}; +use std::sync::Arc; + +pub const DLC_WIRE_MESSAGE_KIND: Kind = Kind::Ephemeral(28_888); + +pub struct NostrDlcHandler { + key: Keys, + pub relay: Url, + dlc: Arc>, + logger: Arc, +} + +impl NostrDlcHandler { + pub fn new(key: Keys, relay: Url, dlc: Arc>, logger: Arc) -> Self { + Self { + key, + relay, + dlc, + logger, + } + } + + pub fn public_key(&self) -> XOnlyPublicKey { + self.key.public_key() + } + + pub fn create_wire_msg_filter(&self) -> Filter { + Filter::new() + .kind(DLC_WIRE_MESSAGE_KIND) + .pubkey(self.key.public_key()) + } + + pub fn create_wire_msg_event( + &self, + to: XOnlyPublicKey, + event_id: Option, + msg: Message, + ) -> Result { + let mut bytes = msg.type_id().encode(); + bytes.extend(msg.encode()); + let content = encrypt(&self.key.secret_key().unwrap(), &to, base64::encode(&bytes))?; + let p_tag = Tag::PubKey(to, None); + let e_tag = event_id.map(|e| Tag::Event(e, None, None)); + let tags = [Some(p_tag), e_tag] + .into_iter() + .flatten() + .collect::>(); + let event = EventBuilder::new(DLC_WIRE_MESSAGE_KIND, content, &tags).to_event(&self.key)?; + + Ok(event) + } + + pub(crate) fn parse_wire_msg_event(&self, event: &Event) -> Result { + // Decrypt the message and parse to bytes + let content = decrypt( + &self.key.secret_key().unwrap(), + &event.pubkey, + &event.content, + )?; + let bytes = base64::decode(content)?; + let mut cursor = lightning::io::Cursor::new(&bytes); + + // Parse the message + let msg_type: u16 = Readable::read(&mut cursor)?; + let Some(wire) = read_dlc_message(msg_type, &mut cursor)? else { + log_warn!(self.logger, "Error reading message {}", bytes.to_hex()); + return Err(MutinyError::DLCManagerError); + }; + + match wire { + WireMessage::Message(msg) => Ok(msg), + WireMessage::SegmentStart(_) | WireMessage::SegmentChunk(_) => { + Err(MutinyError::InvalidArgumentsError) + } + } + } + + /// Handles a DLC wire event, returns an event to reply with if needed + pub async fn handle_dlc_wire_event(&self, event: Event) -> Result, MutinyError> { + // Only handle DLC wire messages + if event.kind != DLC_WIRE_MESSAGE_KIND { + return Ok(None); + } + log_info!(self.logger, "Received DLC wire message"); + + let msg = self.parse_wire_msg_event(&event).map_err(|e| { + log_warn!(self.logger, "Error parsing DLC wire message: {e:?}"); + e + })?; + let pubkey = PublicKey::from_slice(&event.pubkey.public_key(Parity::Even).serialize()) + .expect("converting pubkey between crates should not fail"); + let mut dlc = self.dlc.manager.lock().await; + + let msg_opt = dlc.on_dlc_message(&msg, pubkey).map_err(|e| { + log_warn!(self.logger, "Error handling DLC message: {e:?}"); + e + })?; + if let Some(msg) = msg_opt { + let event = self.create_wire_msg_event(event.pubkey, Some(event.id), msg)?; + return Ok(Some(event)); + } + + Ok(None) + } +} + +#[cfg(test)] +#[cfg(target_arch = "wasm32")] +mod wasm_test { + use super::*; + use crate::storage::MemoryStorage; + use crate::test_utils::create_node; + use dlc_messages::OfferDlc; + use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; + + wasm_bindgen_test_configure!(run_in_browser); + + #[test] + async fn test_dlc_serialization() { + let storage = MemoryStorage::default(); + let node = create_node(storage.clone()).await; + let dlc = Arc::new(DlcHandler::new(node.wallet.clone(), node.logger.clone()).unwrap()); + let handler = NostrDlcHandler::new( + Keys::generate(), + Url::parse("https://nostr.mutinywallet.com").unwrap(), + dlc, + node.logger.clone(), + ); + + let input = include_str!("../../test_inputs/dlc_offer.json"); + let offer: OfferDlc = serde_json::from_str(input).unwrap(); + let msg = Message::Offer(offer.clone()); + + let event = handler + .create_wire_msg_event(handler.public_key(), None, msg) + .unwrap(); + let parsed = handler.parse_wire_msg_event(&event).unwrap(); + + match parsed { + Message::Offer(parsed_offer) => assert_eq!(offer, parsed_offer), + _ => panic!("Wrong message type"), + } + } +} diff --git a/mutiny-core/src/nostr/mod.rs b/mutiny-core/src/nostr/mod.rs index 844ae5299..b8ee63d72 100644 --- a/mutiny-core/src/nostr/mod.rs +++ b/mutiny-core/src/nostr/mod.rs @@ -1,7 +1,9 @@ +use crate::dlc::DlcHandler; use crate::labels::LabelStorage; use crate::logging::MutinyLogger; use crate::node::Node; use crate::nodemanager::NodeManager; +use crate::nostr::dlc::NostrDlcHandler; use crate::nostr::nip49::{NIP49BudgetPeriod, NIP49URI}; use crate::nostr::nwc::{ BudgetPeriod, BudgetedSpendingConditions, NostrWalletConnect, NwcProfile, NwcProfileTag, @@ -22,6 +24,7 @@ use lightning::{log_error, log_warn}; use nostr::key::SecretKey; use nostr::nips::nip47::*; use nostr::prelude::{decrypt, encrypt}; +use nostr::Url; use nostr::{Event, EventBuilder, EventId, Filter, Keys, Kind, Tag}; use nostr_sdk::{Client, RelayPoolNotification}; use std::str::FromStr; @@ -29,10 +32,12 @@ use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; use std::time::Duration; +pub mod dlc; pub mod nip49; pub mod nwc; const NWC_ACCOUNT_INDEX: u32 = 1; +const DLC_ACCOUNT_INDEX: u32 = 1; const USER_NWC_PROFILE_START_INDEX: u32 = 1000; const NWC_STORAGE_KEY: &str = "nwc_profiles"; @@ -71,6 +76,9 @@ pub struct NostrManager { pub primary_key: Keys, /// Separate profiles for each nostr wallet connect string pub(crate) nwc: Arc>>, + /// Handler for DLC messages + pub(crate) dlc_handler: Arc>, + /// Storage pub storage: S, /// Lock for pending nwc invoices pending_nwc_lock: Arc>, @@ -89,6 +97,8 @@ impl NostrManager { .map(|x| x.profile.relay.clone()) .collect(); + relays.push(self.dlc_handler.relay.to_string()); + // remove duplicates relays.sort(); relays.dedup(); @@ -972,6 +982,7 @@ impl NostrManager { /// Creates a new NostrManager pub fn from_mnemonic( xprivkey: ExtendedPrivKey, + dlc: Arc>, storage: S, logger: Arc, ) -> Result { @@ -989,10 +1000,16 @@ impl NostrManager { .map(|profile| NostrWalletConnect::new(&context, xprivkey, profile).unwrap()) .collect(); + // todo want to rotate keys between contracts + let dlc_key = Self::derive_nostr_key(&context, xprivkey, DLC_ACCOUNT_INDEX, None, None)?; + let relay = Url::parse("wss://relay.damus.io").unwrap(); + let dlc_handler = NostrDlcHandler::new(dlc_key, relay, dlc, logger.clone()); + Ok(Self { xprivkey, primary_key, nwc: Arc::new(RwLock::new(nwc)), + dlc_handler: Arc::new(dlc_handler), storage, pending_nwc_lock: Arc::new(Mutex::new(())), logger, @@ -1030,26 +1047,50 @@ fn get_next_nwc_index( #[cfg(test)] mod test { use super::*; + use crate::fees::MutinyFeeEstimator; + use crate::multiesplora::MultiEsploraClient; + use crate::onchain::OnChainWallet; use crate::storage::MemoryStorage; use bip39::Mnemonic; use bitcoin::util::bip32::ExtendedPrivKey; use bitcoin::Network; + use esplora_client::Builder; use futures::executor::block_on; use lightning_invoice::Bolt11Invoice; use nostr::key::XOnlyPublicKey; use std::str::FromStr; + use std::sync::atomic::AtomicBool; fn create_nostr_manager() -> NostrManager { let mnemonic = Mnemonic::from_str("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about").expect("could not generate"); let xprivkey = - ExtendedPrivKey::new_master(Network::Bitcoin, &mnemonic.to_seed("")).unwrap(); + ExtendedPrivKey::new_master(Network::Regtest, &mnemonic.to_seed("")).unwrap(); let storage = MemoryStorage::new(None, None, None); let logger = Arc::new(MutinyLogger::default()); - NostrManager::from_mnemonic(xprivkey, storage, logger).unwrap() + let esplora = Arc::new( + Builder::new("https://mutinynet.com/api") + .build_async() + .unwrap(), + ); + let esplora = Arc::new(MultiEsploraClient::new(vec![esplora])); + let fees = MutinyFeeEstimator::new(storage.clone(), esplora.clone(), logger.clone()); + let wallet = OnChainWallet::new( + xprivkey, + storage.clone(), + Network::Regtest, + esplora.clone(), + Arc::new(fees), + Arc::new(AtomicBool::default()), + logger.clone(), + ) + .unwrap(); + let dlc = DlcHandler::new(Arc::new(wallet), logger.clone()).unwrap(); + + NostrManager::from_mnemonic(xprivkey, Arc::new(dlc), storage, logger).unwrap() } #[test] diff --git a/mutiny-core/src/nostr/nwc.rs b/mutiny-core/src/nostr/nwc.rs index f164b309d..2875ccce1 100644 --- a/mutiny-core/src/nostr/nwc.rs +++ b/mutiny-core/src/nostr/nwc.rs @@ -1100,13 +1100,14 @@ mod test { #[cfg(target_arch = "wasm32")] mod wasm_test { use super::*; + use crate::dlc::DlcHandler; use crate::event::{MillisatAmount, PaymentInfo}; use crate::logging::MutinyLogger; use crate::node::MockLnNode; use crate::nodemanager::MutinyInvoice; use crate::nostr::ProfileType; use crate::storage::MemoryStorage; - use crate::test_utils::{create_dummy_invoice, create_node, create_nwc_request}; + use crate::test_utils::{create_dummy_invoice, create_node, create_nwc_request, create_wallet}; use bitcoin::hashes::Hash; use bitcoin::secp256k1::ONE_KEY; use bitcoin::Network; @@ -1146,8 +1147,10 @@ mod wasm_test { node.skip_hodl_invoices = false; // allow hodl invoices let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let dlc = Arc::new(DlcHandler::new(node.wallet.clone(), node.logger.clone()).unwrap()); let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), node.logger.clone()).unwrap(); + NostrManager::from_mnemonic(xprivkey, dlc, storage.clone(), node.logger.clone()) + .unwrap(); let profile = nostr_manager .create_new_profile( @@ -1190,8 +1193,10 @@ mod wasm_test { let node = create_node(storage.clone()).await; let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let dlc = Arc::new(DlcHandler::new(node.wallet.clone(), node.logger.clone()).unwrap()); let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), node.logger.clone()).unwrap(); + NostrManager::from_mnemonic(xprivkey, dlc, storage.clone(), node.logger.clone()) + .unwrap(); let profile = nostr_manager .create_new_profile( @@ -1345,9 +1350,12 @@ mod wasm_test { #[test] async fn test_clear_expired_pending_invoices() { let storage = MemoryStorage::default(); + let node = create_node(storage.clone()).await; let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let dlc = Arc::new(DlcHandler::new(node.wallet.clone(), node.logger.clone()).unwrap()); let nostr_manager = NostrManager::from_mnemonic( xprivkey, + dlc, storage.clone(), Arc::new(MutinyLogger::default()), ) @@ -1395,8 +1403,10 @@ mod wasm_test { let node = create_node(storage.clone()).await; let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); + let dlc = Arc::new(DlcHandler::new(node.wallet.clone(), node.logger.clone()).unwrap()); let nostr_manager = - NostrManager::from_mnemonic(xprivkey, storage.clone(), node.logger.clone()).unwrap(); + NostrManager::from_mnemonic(xprivkey, dlc, storage.clone(), node.logger.clone()) + .unwrap(); let budget = 10_000; let profile = nostr_manager @@ -1474,8 +1484,11 @@ mod wasm_test { Ok(mutiny_invoice) }); - let xprivkey = ExtendedPrivKey::new_master(Network::Regtest, &[0; 64]).unwrap(); - let nostr_manager = NostrManager::from_mnemonic(xprivkey, storage.clone(), logger).unwrap(); + let wallet = Arc::new(create_wallet(storage.clone())); + let xprivkey = wallet.xprivkey; + let dlc = Arc::new(DlcHandler::new(wallet, logger.clone()).unwrap()); + let nostr_manager = + NostrManager::from_mnemonic(xprivkey, dlc, storage.clone(), logger).unwrap(); let budget = 10_000; let profile = nostr_manager diff --git a/mutiny-core/src/onchain.rs b/mutiny-core/src/onchain.rs index 3ef4cace8..bc4a34655 100644 --- a/mutiny-core/src/onchain.rs +++ b/mutiny-core/src/onchain.rs @@ -29,13 +29,14 @@ use crate::utils::{now, sleep}; #[derive(Clone)] pub struct OnChainWallet { + pub(crate) xprivkey: ExtendedPrivKey, pub wallet: Arc>>>, pub(crate) storage: S, pub network: Network, pub blockchain: Arc, pub fees: Arc>, pub(crate) stop: Arc, - logger: Arc, + pub(crate) logger: Arc, } impl OnChainWallet { @@ -60,6 +61,7 @@ impl OnChainWallet { )?; Ok(OnChainWallet { + xprivkey, wallet: Arc::new(RwLock::new(wallet)), storage: db, network, diff --git a/mutiny-core/src/storage.rs b/mutiny-core/src/storage.rs index a39810451..b716ef2ba 100644 --- a/mutiny-core/src/storage.rs +++ b/mutiny-core/src/storage.rs @@ -75,6 +75,16 @@ pub struct VersionedValue { pub value: Value, } +impl VersionedValue { + pub fn get_value(&self) -> Result + where + T: for<'de> Deserialize<'de>, + { + let value: T = serde_json::from_value(self.value.clone())?; + Ok(value) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeviceLock { pub time: u32, diff --git a/mutiny-core/src/test_utils.rs b/mutiny-core/src/test_utils.rs index b4a2cc166..ee51e8e7f 100644 --- a/mutiny-core/src/test_utils.rs +++ b/mutiny-core/src/test_utils.rs @@ -51,6 +51,38 @@ pub fn create_nwc_request(nwc: &NostrWalletConnectURI, invoice: String) -> Event .unwrap() } +pub(crate) fn create_wallet(storage: S) -> OnChainWallet { + let logger = Arc::new(MutinyLogger::default()); + let network = Network::Regtest; + let seed = generate_seed(12).unwrap(); + let xprivkey = ExtendedPrivKey::new_master(network, &seed.to_seed("")).unwrap(); + + let esplora_server_url = get_esplora_url(network, None); + let client = Arc::new( + esplora_client::Builder::new(&esplora_server_url) + .build_async() + .unwrap(), + ); + let esplora = MultiEsploraClient::new(vec![client]); + let esplora = Arc::new(esplora); + let fee_estimator = Arc::new(MutinyFeeEstimator::new( + storage.clone(), + esplora.clone(), + logger.clone(), + )); + + OnChainWallet::new( + xprivkey, + storage, + network, + esplora, + fee_estimator, + Arc::new(AtomicBool::new(false)), + logger, + ) + .unwrap() +} + pub(crate) async fn create_node(storage: S) -> Node { // mark first sync as done so we can execute node functions storage.set_done_first_sync().unwrap(); diff --git a/mutiny-core/src/utils.rs b/mutiny-core/src/utils.rs index f5b260abe..7223d30b2 100644 --- a/mutiny-core/src/utils.rs +++ b/mutiny-core/src/utils.rs @@ -1,15 +1,17 @@ use crate::error::MutinyError; +use bitcoin::hashes::hex::FromHex; use bitcoin::Network; use core::cell::{RefCell, RefMut}; use core::ops::{Deref, DerefMut}; use core::time::Duration; +use dlc_messages::oracle_msgs::OracleAnnouncement; use futures::{ future::{self, Either}, pin_mut, }; use lightning::routing::scoring::{LockableScore, ScoreLookUp, ScoreUpdate}; -use lightning::util::ser::Writeable; use lightning::util::ser::Writer; +use lightning::util::ser::{Readable, Writeable}; use reqwest::Client; pub const FETCH_TIMEOUT: i32 = 30_000; @@ -183,6 +185,14 @@ pub fn get_monitor_version(bytes: &[u8]) -> u64 { u64::from_be_bytes(bytes[2..10].try_into().unwrap()) } +/// Parses a hex string into an oracle announcement. +pub fn oracle_announcement_from_hex(hex: &str) -> Result { + let bytes: Vec = FromHex::from_hex(hex).map_err(|_| MutinyError::InvalidArgumentsError)?; + let mut cursor = lightning::io::Cursor::new(bytes); + + OracleAnnouncement::read(&mut cursor).map_err(|_| MutinyError::InvalidArgumentsError) +} + #[cfg(not(test))] pub const HODL_INVOICE_NODES: [&str; 1] = ["031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581"]; diff --git a/mutiny-core/test_inputs/dlc_offer.json b/mutiny-core/test_inputs/dlc_offer.json new file mode 100644 index 000000000..53e4a97e3 --- /dev/null +++ b/mutiny-core/test_inputs/dlc_offer.json @@ -0,0 +1,163 @@ +{ + "protocolVersion": 1, + "contractFlags": 0, + "chainHash": "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f", + "temporaryContractId": "1212121212121212121212121212121212121212121212121212121212121212", + "contractInfo": { + "singleContractInfo": { + "totalCollateral": 200000000, + "contractInfo": { + "contractDescriptor": { + "numericOutcomeContractDescriptor": { + "numDigits": 13, + "payoutFunction": { + "payoutFunctionPieces": [ + { + "endPoint": { + "eventOutcome": 0, + "outcomePayout": 0, + "extraPrecision": 0 + }, + "payoutCurvePiece": { + "polynomialPayoutCurvePiece": { + "payoutPoints": [ + { + "eventOutcome": 3, + "outcomePayout": 100000000, + "extraPrecision": 0 + } + ] + } + } + }, + { + "endPoint": { + "eventOutcome": 5, + "outcomePayout": 200000000, + "extraPrecision": 0 + }, + "payoutCurvePiece": { + "polynomialPayoutCurvePiece": { + "payoutPoints": [] + } + } + } + ], + "lastEndpoint": { + "eventOutcome": 8191, + "outcomePayout": 200000000, + "extraPrecision": 0 + } + }, + "roundingIntervals": { + "intervals": [ + { + "beginInterval": 0, + "roundingMod": 1 + } + ] + } + } + }, + "oracleInfo": { + "multi": { + "threshold": 2, + "oracleAnnouncements": [ + { + "announcementSignature": "b6a9f79a3c352ffda73ef8db9c37dca6b7310ea4ad96094c7d29f276d72e89c8caebb8b80daa1a69360a5366280e519a58e328d1e3ee89f32716e4ce336607e2", + "oraclePublicKey": "66c05e5845f330791028c62aa2cb5cc9b88145f8295f7ae9e5f044a537b2a560", + "oracleEvent": { + "oracleNonces": [ + "3b584a4049fb2e4f51a0a55e371bc72a55ece6678c89414450f26543bba800bf", + "cd9433d185d08452302e24019134089c38c5b35ce2709398fb2793079ba3be4a", + "e9f506c24e86106ca7e67fa7b38576e868fba87b805386cca622ec7fc67cc781", + "1d23bda4d41bc3829a5dbbacdb94395cf95700ad3e9d84c2ccdbd016699b5aad", + "7631072764bce0db2de17273d48b084e67a5e47531bcc8da4444dd5a41f42c15", + "0876f61639e0cb6845008a43a24eb5110482096de6db5d1e9f03712fa92a0eec", + "cc669fc02f8b1005b92d5a292bde27488fad4d38f61087ed3e2731a62b65b801", + "768145f4edea846adc2a519add9f52b343f31afed366074ef7140b5d272a1a04", + "ae4278283bb7a27c841ad2083a9af1d8e6de0734c756319558bbe18878e01f65", + "df37b1b84d03bc4addf0c902c3d25311a55ce8cca1bdb298292e57e62eb3a51b", + "3afb1b0bdca1e8caff93f984f2ee640c8f766007cf843fbea49386cd97fa27b7", + "b597125db6d01b899ad1b383ba26741766754befd0ed7ff18ed9e2dcf84ed757", + "2bb73e1389ca2bbcb360d52b303d54968b9b614e5e730294fab13cbe39872733", + "2bfbdd9d12e9d7b98c34664bd87d53ca80cf21abfd2e0abc18d9ecaa6a1311d6", + "e586d07ff98f47d1d4e871b7dd2df84c91bd84d352d0fc357cd49b54476bf155" + ], + "eventMaturityEpoch": 1623133104, + "eventDescriptor": { + "digitDecompositionEvent": { + "base": 2, + "isSigned": false, + "unit": "sats/sec", + "precision": 0, + "nbDigits": 15 + } + }, + "eventId": "Test" + } + }, + { + "announcementSignature": "e3b1d3c8c874c339141fe0209dd48c01c62a2fad5668093e2e96be0a88be37a5e7163f7d4e0c9f9111fba60503b775e464d1c798d843ab80a9f1ceacf52a2aeb", + "oraclePublicKey": "97e61039bfdaf898391b8b9825934802b7960a01162d9b4f8a000ae8ce9e1fa6", + "oracleEvent": { + "oracleNonces": [ + "baaa6464e8cfede79012a02723301d7595cb506ea846ac2f5da45c726b003766", + "5970ae9ec1b131ff157da44c204ce1fdd70ad670ece8c704cea32ef7b0ebf462", + "62b5a76b68629d963bb4718ca713e8171365ce51c29941befe0e3dca99cea126", + "5dc0318d4195801ed9b5b86afa12e744ccf7e15e68bc46ef7a30ddb5652d8264", + "18346b71e012f91ac41f222817722717e33c351e19ccf7c9f4378d8e3fd56881", + "4254d6e692e620c080fb29a4e9c67d35e62feb7629d964bed71ac6679414bdd5", + "36d610e88e1fe491fc1055497017e5babc45f17f7aadf9c35800f529a93fa2a6", + "60ff8f86ff75d191bc7cc74dbc31d9f031c54654406fe8ef5f802c9622afcdc4", + "890bd95edc36570b61d3750f89bbb0b6e5a10f12692431e57d7f9362c3f31436", + "3ef80333f95f0277206dd693d5c9362b6326b991c44b7d21f3a63dd8931314a2", + "4b209c8b2e0fdb1b910a57135e146bc0cef46d21915e0bf17cfedb5c7c2296a3", + "49ce4c0b32fdc717d941c87137b9c7b26726236f256a2abf28de9e4870e79d08", + "a9ded37ffee3d0d35d9fb3ae8e0984c250fbbc1db32c08baafee3154bb6e0f51" + ], + "eventMaturityEpoch": 1623133104, + "eventDescriptor": { + "digitDecompositionEvent": { + "base": 2, + "isSigned": false, + "unit": "sats/sec", + "precision": 0, + "nbDigits": 13 + } + }, + "eventId": "Test" + } + } + ], + "oracleParams": { + "maxErrorExp": 2, + "minFailExp": 1, + "maximizeCoverage": false + } + } + } + } + } + }, + "fundingPubkey": "03c12e81303c79abf90a81b900aa3bd3ba8f47ef84c860cb314fc58d531a4d37c6", + "payoutSpk": "0014f65cd6349437fe1f35cb27628f112d7885a5c644", + "payoutSerialId": 4752179201940702056, + "offerCollateral": 100000000, + "fundingInputs": [ + { + "inputSerialId": 3784123604127642354, + "prevTx": "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff03520101ffffffff0200f2052a010000001600143d7834074191c93d7fc2c0a54a6d40efbbfe76430000000000000000266a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf90120000000000000000000000000000000000000000000000000000000000000000000000000", + "prevTxVout": 0, + "sequence": 4294967295, + "maxWitnessLen": 107, + "redeemScript": "" + } + ], + "changeSpk": "0014bf1b2161a681add692a326ace320bc4d8451ee81", + "changeSerialId": 11805380369894479502, + "fundOutputSerialId": 17245645112901355593, + "feeRatePerVb": 2, + "cetLocktime": 1623133104, + "refundLocktime": 1623737904 +} diff --git a/mutiny-wasm/src/indexed_db.rs b/mutiny-wasm/src/indexed_db.rs index 2b8fa8961..d5b83c6de 100644 --- a/mutiny-wasm/src/indexed_db.rs +++ b/mutiny-wasm/src/indexed_db.rs @@ -8,6 +8,7 @@ use mutiny_core::logging::MutinyLogger; use mutiny_core::nodemanager::NodeStorage; use mutiny_core::storage::*; use mutiny_core::vss::*; +use mutiny_core::DLC_CONTRACT_KEY_PREFIX; use mutiny_core::*; use mutiny_core::{ encrypt::Cipher, @@ -344,6 +345,9 @@ impl IndexedDbStorage { } } } + DLC_KEY_INDEX_KEY => { + return Self::handle_versioned_value(kv, vss, current, logger).await; + } key => { if key.starts_with(MONITORS_PREFIX_KEY) { // we can get versions from monitors, so we should compare @@ -370,35 +374,10 @@ impl IndexedDbStorage { return Ok(Some((kv.key, obj.value))); } } - } else if key.starts_with(CHANNEL_MANAGER_KEY) { - // we can get versions from channel manager, so we should compare - match current.get_data::(&kv.key)? { - Some(local) => { - if local.version < kv.version { - let obj = vss.get_object(&kv.key).await?; - if serde_json::from_value::(obj.value.clone()) - .is_ok() - { - return Ok(Some((kv.key, obj.value))); - } - } else { - log_debug!( - logger, - "Skipping vss key {} with version {}, current version is {}", - kv.key, - kv.version, - local.version - ); - return Ok(None); - } - } - None => { - let obj = vss.get_object(&kv.key).await?; - if serde_json::from_value::(obj.value.clone()).is_ok() { - return Ok(Some((kv.key, obj.value))); - } - } - } + } else if key.starts_with(CHANNEL_MANAGER_KEY) + || key.starts_with(DLC_CONTRACT_KEY_PREFIX) + { + return Self::handle_versioned_value(kv, vss, current, logger).await; } } } @@ -413,6 +392,42 @@ impl IndexedDbStorage { Ok(None) } + async fn handle_versioned_value( + kv: KeyVersion, + vss: &MutinyVssClient, + current: &MemoryStorage, + logger: &MutinyLogger, + ) -> Result, MutinyError> { + // we can get versions from VersionedValue so we should compare + match current.get_data::(&kv.key)? { + Some(local) => { + if local.version < kv.version { + let obj = vss.get_object(&kv.key).await?; + if serde_json::from_value::(obj.value.clone()).is_ok() { + return Ok(Some((kv.key, obj.value))); + } + } else { + log_debug!( + logger, + "Skipping vss key {} with version {}, current version is {}", + kv.key, + kv.version, + local.version + ); + return Ok(None); + } + } + None => { + let obj = vss.get_object(&kv.key).await?; + if serde_json::from_value::(obj.value.clone()).is_ok() { + return Ok(Some((kv.key, obj.value))); + } + } + } + + Ok(None) + } + async fn build_indexed_db_database() -> Result { let rexie = Rexie::builder(WALLET_DATABASE_NAME) .version(1) diff --git a/mutiny-wasm/src/lib.rs b/mutiny-wasm/src/lib.rs index 281174743..8d96799cd 100644 --- a/mutiny-wasm/src/lib.rs +++ b/mutiny-wasm/src/lib.rs @@ -24,10 +24,13 @@ use bitcoin::util::bip32::ExtendedPrivKey; use bitcoin::{Address, Network, OutPoint, Transaction, Txid}; use futures::lock::Mutex; use gloo_utils::format::JsValueSerdeExt; +use lightning::io::Cursor; use lightning::routing::gossip::NodeId; +use lightning::util::ser::Readable; use lightning_invoice::Bolt11Invoice; use lnurl::lnurl::LnUrl; use mutiny_core::auth::MutinyAuthClient; +use mutiny_core::dlc_messages::oracle_msgs::OracleAttestation; use mutiny_core::encrypt::encryption_key_from_pass; use mutiny_core::lnurlauth::AuthManager; use mutiny_core::nostr::nip49::NIP49URI; @@ -35,12 +38,13 @@ use mutiny_core::nostr::nwc::{BudgetedSpendingConditions, NwcProfileTag, Spendin use mutiny_core::redshift::RedshiftManager; use mutiny_core::redshift::RedshiftRecipient; use mutiny_core::storage::{DeviceLock, MutinyStorage, DEVICE_LOCK_KEY}; -use mutiny_core::utils::sleep; +use mutiny_core::utils::{oracle_announcement_from_hex, sleep}; use mutiny_core::vss::MutinyVssClient; use mutiny_core::{labels::LabelStorage, nodemanager::NodeManager}; use mutiny_core::{logging::MutinyLogger, nostr::ProfileType}; use nostr::key::XOnlyPublicKey; use nostr::prelude::FromBech32; +use nostr::ToBech32; use payjoin::UriExt; use std::str::FromStr; use std::sync::Arc; @@ -1532,6 +1536,100 @@ impl MutinyWallet { Ok(()) } + #[wasm_bindgen] + pub async fn test_send_dlc_offer( + &self, + collateral: u64, + descriptor: &JsValue, /* EnumDescriptor */ + oracle_announcement: String, + npub_str: String, + ) -> Result { + let oracle_announcement = oracle_announcement_from_hex(&oracle_announcement)?; + let descriptor: mutiny_core::dlc_manager::contract::enum_descriptor::EnumDescriptor = + descriptor.into_serde().map_err(|e| { + log::error!("Error: {e:?}"); + log::error!("Descriptor: {descriptor:?}"); + MutinyJsError::InvalidArgumentsError + })?; + let contract_input = mutiny_core::create_contract_input( + collateral, + descriptor, + oracle_announcement.clone(), + )?; + let pubkey = XOnlyPublicKey::from_bech32(&npub_str)?; + let res = self + .inner + .send_dlc_offer(&contract_input, oracle_announcement, pubkey) + .await?; + + Ok(res.to_hex()) + } + + #[wasm_bindgen] + pub async fn send_dlc_offer( + &self, + contract_input: &JsValue, /* ContractInput */ + oracle_announcement: String, + npub_str: String, + ) -> Result { + let oracle_announcement = oracle_announcement_from_hex(&oracle_announcement)?; + let contract_input = contract_input + .into_serde() + .map_err(|_| MutinyJsError::InvalidArgumentsError)?; + let pubkey = XOnlyPublicKey::from_bech32(&npub_str)?; + let res = self + .inner + .send_dlc_offer(&contract_input, oracle_announcement, pubkey) + .await?; + + Ok(res.to_hex()) + } + + #[wasm_bindgen] + pub async fn accept_dlc_offer(&self, contract_id: String) -> Result<(), MutinyJsError> { + let contract_id: [u8; 32] = FromHex::from_hex(&contract_id)?; + self.inner.accept_dlc_offer(contract_id).await?; + + Ok(()) + } + + #[wasm_bindgen] + pub async fn reject_dlc_offer(&self, contract_id: String) -> Result<(), MutinyJsError> { + let contract_id: [u8; 32] = FromHex::from_hex(&contract_id)?; + self.inner.reject_dlc_offer(contract_id).await?; + + Ok(()) + } + + #[wasm_bindgen] + pub async fn close_dlc( + &self, + contract_id: String, + attestation: String, + ) -> Result<(), MutinyJsError> { + let contract_id: [u8; 32] = FromHex::from_hex(&contract_id)?; + let attestation_bytes: Vec = FromHex::from_hex(&attestation)?; + let mut cursor = Cursor::new(attestation_bytes); + let attestation: OracleAttestation = + Readable::read(&mut cursor).map_err(|_| MutinyJsError::InvalidArgumentsError)?; + self.inner.close_dlc(contract_id, attestation).await?; + + Ok(()) + } + + #[wasm_bindgen] + pub async fn list_dlcs(&self) -> Result { + let dlcs = self.inner.list_dlcs().await?; + + let ret: Vec = dlcs.into_iter().map(|d| d.into()).collect(); + Ok(JsValue::from_serde(&ret)?) + } + + #[wasm_bindgen] + pub fn get_dlc_key(&self) -> String { + self.inner.get_dlc_key().to_bech32().unwrap() + } + /// Resets the scorer and network graph. This can be useful if you get stuck in a bad state. #[wasm_bindgen] pub async fn reset_router(&self) -> Result<(), MutinyJsError> { diff --git a/mutiny-wasm/src/models.rs b/mutiny-wasm/src/models.rs index 2f64b9cda..b40cf69b2 100644 --- a/mutiny-wasm/src/models.rs +++ b/mutiny-wasm/src/models.rs @@ -8,6 +8,7 @@ use gloo_utils::format::JsValueSerdeExt; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription}; use lnurl::lightning_address::LightningAddress; use lnurl::lnurl::LnUrl; +use mutiny_core::dlc_manager::contract; use mutiny_core::labels::Contact as MutinyContact; use mutiny_core::nostr::nwc::SpendingConditions; use mutiny_core::redshift::{RedshiftRecipient, RedshiftStatus}; @@ -1208,3 +1209,171 @@ impl TryFrom for BudgetPeriod { } } } + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[wasm_bindgen] +pub struct Contract { + id: String, + state: String, + counter_party: String, + pub cet_locktime: u32, + pub is_offer_party: Option, + funding_txid: Option, + closing_txid: Option, +} + +impl From for Contract { + fn from(value: contract::Contract) -> Self { + let state = match value { + contract::Contract::Offered(_) => "Offered", + contract::Contract::Accepted(_) => "Accepted", + contract::Contract::Signed(_) => "Signed", + contract::Contract::Confirmed(_) => "Confirmed", + contract::Contract::PreClosed(_) => "PreClosed", + contract::Contract::Closed(_) => "Closed", + contract::Contract::Refunded(_) => "Refunded", + contract::Contract::FailedAccept(_) => "FailedAccept", + contract::Contract::FailedSign(_) => "FailedSign", + contract::Contract::Rejected(_) => "Rejected", + } + .to_string(); + + let cet_locktime = match &value { + contract::Contract::Offered(x) => x.cet_locktime, + contract::Contract::Accepted(x) => x.offered_contract.cet_locktime, + contract::Contract::Signed(x) => x.accepted_contract.offered_contract.cet_locktime, + contract::Contract::Confirmed(x) => x.accepted_contract.offered_contract.cet_locktime, + contract::Contract::PreClosed(x) => { + x.signed_contract + .accepted_contract + .offered_contract + .cet_locktime + } + contract::Contract::Closed(x) => x + .signed_cet + .as_ref() + .map(|x| x.lock_time.0) + .unwrap_or_default(), + contract::Contract::Refunded(x) => x.accepted_contract.offered_contract.cet_locktime, + contract::Contract::FailedAccept(x) => x.offered_contract.cet_locktime, + contract::Contract::FailedSign(x) => x.accepted_contract.offered_contract.cet_locktime, + contract::Contract::Rejected(x) => x.cet_locktime, + }; + + let is_offer_party = match &value { + contract::Contract::Offered(x) => Some(x.is_offer_party), + contract::Contract::Accepted(x) => Some(x.offered_contract.is_offer_party), + contract::Contract::Signed(x) => { + Some(x.accepted_contract.offered_contract.is_offer_party) + } + contract::Contract::Confirmed(x) => { + Some(x.accepted_contract.offered_contract.is_offer_party) + } + contract::Contract::PreClosed(x) => Some( + x.signed_contract + .accepted_contract + .offered_contract + .is_offer_party, + ), + contract::Contract::Closed(_) => None, + contract::Contract::Refunded(x) => { + Some(x.accepted_contract.offered_contract.is_offer_party) + } + contract::Contract::FailedAccept(x) => Some(x.offered_contract.is_offer_party), + contract::Contract::FailedSign(x) => { + Some(x.accepted_contract.offered_contract.is_offer_party) + } + contract::Contract::Rejected(x) => Some(x.is_offer_party), + }; + + let funding_txid: Option = match &value { + contract::Contract::Offered(_) => None, + contract::Contract::Accepted(_) => None, + contract::Contract::Signed(x) => { + Some(x.accepted_contract.dlc_transactions.fund.txid().to_hex()) + } + contract::Contract::Confirmed(x) => { + Some(x.accepted_contract.dlc_transactions.fund.txid().to_hex()) + } + contract::Contract::PreClosed(x) => Some( + x.signed_contract + .accepted_contract + .dlc_transactions + .fund + .txid() + .to_hex(), + ), + contract::Contract::Closed(x) => x + .signed_cet + .as_ref() + .map(|t| t.input[0].previous_output.txid.to_hex()), + contract::Contract::Refunded(x) => { + Some(x.accepted_contract.dlc_transactions.fund.txid().to_hex()) + } + contract::Contract::FailedAccept(_) => None, + contract::Contract::FailedSign(_) => None, + contract::Contract::Rejected(_) => None, + }; + + let closing_txid: Option = match &value { + contract::Contract::Offered(_) => None, + contract::Contract::Accepted(_) => None, + contract::Contract::Signed(_) => None, + contract::Contract::Confirmed(_) => None, + contract::Contract::PreClosed(x) => Some(x.signed_cet.txid().to_hex()), + contract::Contract::Closed(x) => x.signed_cet.as_ref().map(|t| t.txid().to_hex()), + contract::Contract::Refunded(x) => { + Some(x.accepted_contract.dlc_transactions.refund.txid().to_hex()) + } + contract::Contract::FailedAccept(_) => None, + contract::Contract::FailedSign(_) => None, + contract::Contract::Rejected(_) => None, + }; + + let counter_party = { + let xonly = value.get_counter_party_id().x_only_public_key().0; + XOnlyPublicKey::from_slice(&xonly.serialize()) + .unwrap() + .to_bech32() + .unwrap() + }; + + Contract { + id: value.get_id().to_hex(), + state, + counter_party, + cet_locktime, + is_offer_party, + funding_txid, + closing_txid, + } + } +} + +#[wasm_bindgen] +impl Contract { + #[wasm_bindgen(getter)] + pub fn value(&self) -> JsValue { + JsValue::from_serde(&serde_json::to_value(self).unwrap()).unwrap() + } + + #[wasm_bindgen(getter)] + pub fn id(&self) -> String { + self.id.clone() + } + + #[wasm_bindgen(getter)] + pub fn counter_party(&self) -> String { + self.counter_party.clone() + } + + #[wasm_bindgen(getter)] + pub fn funding_txid(&self) -> Option { + self.funding_txid.clone() + } + + #[wasm_bindgen(getter)] + pub fn closing_txid(&self) -> Option { + self.closing_txid.clone() + } +}