From 34ffa9deb5fc3f9bcc1fda88755cd8ba4b4382e5 Mon Sep 17 00:00:00 2001 From: michael1011 Date: Thu, 13 Feb 2025 11:38:32 +0100 Subject: [PATCH 1/2] feat: swap recovery rescan --- boltzr/Cargo.lock | 41 ++ boltzr/Cargo.toml | 2 + boltzr/src/api/lightning.rs | 15 +- boltzr/src/api/mod.rs | 12 +- boltzr/src/api/recovery.rs | 146 +++++ boltzr/src/config.rs | 25 +- boltzr/src/currencies.rs | 31 +- boltzr/src/db/helpers/chain_swap.rs | 53 +- boltzr/src/db/helpers/keys.rs | 35 + boltzr/src/db/helpers/mod.rs | 4 +- boltzr/src/db/helpers/swap.rs | 38 +- boltzr/src/db/models/chain_swap.rs | 10 +- boltzr/src/db/models/keys.rs | 8 + boltzr/src/db/models/mod.rs | 49 ++ boltzr/src/db/models/swap.rs | 6 + boltzr/src/db/schema.rs | 23 +- boltzr/src/evm/manager.rs | 2 +- boltzr/src/grpc/server.rs | 16 +- boltzr/src/grpc/service.rs | 47 +- boltzr/src/main.rs | 7 + boltzr/src/service/lightning_info.rs | 15 +- boltzr/src/service/mod.rs | 72 ++- boltzr/src/service/recovery.rs | 604 ++++++++++++++++++ boltzr/src/swap/expiration/custom_expiry.rs | 3 +- boltzr/src/swap/expiration/invoice_expiry.rs | 18 +- boltzr/src/swap/filters.rs | 49 +- boltzr/src/wallet/bitcoin.rs | 78 ++- boltzr/src/wallet/elements.rs | 105 ++- boltzr/src/wallet/keys.rs | 73 +++ boltzr/src/wallet/mod.rs | 15 +- docker/regtest/nginx.conf | 49 ++ lib/Boltz.ts | 6 +- lib/wallet/WalletManager.ts | 6 +- package.json | 5 +- swagger-spec.json | 2 +- swagger.js | 2 +- test/integration/lightning/LndClient.spec.ts | 2 +- .../lightning/PendingPaymentTracker.spec.ts | 3 +- .../lightning/cln/ClnClient.spec.ts | 2 +- .../service/TimeoutDeltaProvider.spec.ts | 2 +- .../sidecar/DecodedInvoice.spec.ts | 2 +- test/integration/sidecar/Sidecar.spec.ts | 2 +- test/integration/sidecar/Utils.ts | 27 +- test/integration/sidecar/config.toml | 18 +- test/integration/sidecar/seed.dat | 1 + test/integration/swap/UtxoNursery.spec.ts | 5 +- 46 files changed, 1628 insertions(+), 108 deletions(-) create mode 100644 boltzr/src/api/recovery.rs create mode 100644 boltzr/src/db/helpers/keys.rs create mode 100644 boltzr/src/db/models/keys.rs create mode 100644 boltzr/src/service/recovery.rs create mode 100644 boltzr/src/wallet/keys.rs create mode 100644 docker/regtest/nginx.conf create mode 100644 test/integration/sidecar/seed.dat diff --git a/boltzr/Cargo.lock b/boltzr/Cargo.lock index 9057be1e..21496bdd 100644 --- a/boltzr/Cargo.lock +++ b/boltzr/Cargo.lock @@ -1614,6 +1614,12 @@ dependencies = [ "bitcoin-internals 0.3.0", ] +[[package]] +name = "bitcoin_hashes" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90064b8dee6815a6470d60bad07bbbaee885c0e12d04177138fa3291a01b7bc4" + [[package]] name = "bitcoin_hashes" version = "0.14.0" @@ -1709,6 +1715,7 @@ dependencies = [ "diesel_migrations", "dirs", "elements", + "elements-miniscript", "eventsource-client", "fedimint-tonic-lnd", "flate2", @@ -1735,6 +1742,7 @@ dependencies = [ "redis", "reqwest", "rstest", + "rust-bip39", "rust-s3", "serde", "serde_json", @@ -2526,6 +2534,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "elements-miniscript" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571fa105690f83c7833df2109eb2e14ca0e62d633d2624ffcb166ff18a3da870" +dependencies = [ + "bitcoin", + "elements", + "miniscript", +] + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -3923,6 +3942,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniscript" +version = "12.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd3c9608217b0d6fa9c9c8ddd875b85ab72bd4311cfc8db35e1b5a08fc11f4d" +dependencies = [ + "bech32 0.11.0", + "bitcoin", +] + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -5334,6 +5363,18 @@ dependencies = [ "libusb1-sys", ] +[[package]] +name = "rust-bip39" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f868fd508789837c62557c8eb3c2ce6891ed3b4961e96e14f068d35299c270f" +dependencies = [ + "bitcoin_hashes 0.11.0", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + [[package]] name = "rust-ini" version = "0.21.1" diff --git a/boltzr/Cargo.toml b/boltzr/Cargo.toml index 080b5a09..6c1fdb99 100644 --- a/boltzr/Cargo.toml +++ b/boltzr/Cargo.toml @@ -127,6 +127,8 @@ csv = "1.3.1" axum-extra = { version = "0.10.0", features = ["typed-header"] } redis = { version = "0.29.0", features = ["tokio-comp", "r2d2"] } bytes = "1.10.0" +rust-bip39 = "1.0.0" +elements-miniscript = "0.4.0" [build-dependencies] built = { version = "0.7.7", features = ["git2"] } diff --git a/boltzr/src/api/lightning.rs b/boltzr/src/api/lightning.rs index a4c289da..e003d473 100644 --- a/boltzr/src/api/lightning.rs +++ b/boltzr/src/api/lightning.rs @@ -168,8 +168,10 @@ mod test { use axum::Router; use axum::body::Body; use axum::extract::Request; + use bip39::Mnemonic; use http_body_util::BodyExt; use rstest::*; + use std::str::FromStr; use tower::ServiceExt; fn setup_router(manager: MockManager) -> Router { @@ -196,7 +198,18 @@ mod test { manager.expect_get_currency().returning(move |_| { Some(Currency { network: Network::Regtest, - wallet: Arc::new(crate::wallet::Bitcoin::new(Network::Regtest)), + wallet: Arc::new( + crate::wallet::Bitcoin::new( + Network::Regtest, + &Mnemonic::from_str( + "test test test test test test test test test test test junk", + ) + .unwrap() + .to_seed(""), + "m/0/0".to_string(), + ) + .unwrap(), + ), cln: Some(cln.clone()), lnd: None, chain: None, diff --git a/boltzr/src/api/mod.rs b/boltzr/src/api/mod.rs index 6aa29192..2c3ad894 100644 --- a/boltzr/src/api/mod.rs +++ b/boltzr/src/api/mod.rs @@ -1,4 +1,5 @@ use crate::api::errors::error_middleware; +use crate::api::recovery::swap_recovery; use crate::api::sse::sse_handler; use crate::api::stats::get_stats; #[cfg(feature = "metrics")] @@ -18,6 +19,7 @@ use ws::types::SwapStatus; mod errors; mod headers; mod lightning; +mod recovery; mod sse; mod stats; mod types; @@ -123,6 +125,7 @@ where "/v2/swap/{swap_type}/stats/{from}/{to}", get(get_stats::), ) + .route("/v2/swap/recovery", post(swap_recovery::)) .route( "/v2/lightning/{currency}/node/{node}", get(lightning::node_info::), @@ -144,12 +147,10 @@ pub mod test { use crate::api::ws::status::SwapInfos; use crate::api::ws::types::SwapStatus; use crate::api::{Config, Server}; - use crate::cache::Redis; use crate::service::Service; use crate::swap::manager::test::MockManager; use async_trait::async_trait; use reqwest::StatusCode; - use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use tokio::sync::broadcast::Sender; @@ -196,12 +197,7 @@ pub mod test { }, cancel.clone(), Arc::new(MockManager::new()), - Arc::new(Service::new::( - Arc::new(HashMap::new()), - None, - None, - None, - )), + Arc::new(Service::new_mocked_prometheus(false)), Fetcher { status_tx: status_tx.clone(), }, diff --git a/boltzr/src/api/recovery.rs b/boltzr/src/api/recovery.rs new file mode 100644 index 00000000..702cd1e0 --- /dev/null +++ b/boltzr/src/api/recovery.rs @@ -0,0 +1,146 @@ +use crate::api::ServerState; +use crate::api::errors::AxumError; +use crate::api::ws::status::SwapInfos; +use crate::swap::manager::SwapManager; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::{Extension, Json}; +use bitcoin::bip32::Xpub; +use serde::de::Visitor; +use serde::{Deserialize, Deserializer}; +use std::fmt; +use std::str::FromStr; +use std::sync::Arc; + +struct XpubDeserialize(Xpub); + +impl<'de> Deserialize<'de> for XpubDeserialize { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct XpubDeserializeVisitor; + + impl Visitor<'_> for XpubDeserializeVisitor { + type Value = XpubDeserialize; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid xpub") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match Xpub::from_str(value) { + Ok(xpub) => Ok(XpubDeserialize(xpub)), + Err(err) => Err(E::custom(format!("invalid xpub: {}", err))), + } + } + } + + deserializer.deserialize_string(XpubDeserializeVisitor) + } +} + +#[derive(Deserialize)] +pub struct RecoveryParams { + xpub: XpubDeserialize, +} + +pub async fn swap_recovery( + Extension(state): Extension>>, + Json(RecoveryParams { xpub }): Json, +) -> anyhow::Result +where + S: SwapInfos + Send + Sync + Clone + 'static, + M: SwapManager + Send + Sync + 'static, +{ + let res = state.service.swap_recovery.recover_xpub(&xpub.0)?; + Ok((StatusCode::OK, Json(res)).into_response()) +} + +#[cfg(test)] +mod test { + use crate::api::errors::ApiError; + use crate::api::test::Fetcher; + use crate::api::ws::types::SwapStatus; + use crate::api::{Server, ServerState}; + use crate::service::Service; + use crate::service::test::RecoverableSwap; + use crate::swap::manager::test::MockManager; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::{Extension, Router}; + use http_body_util::BodyExt; + use std::sync::Arc; + use tower::ServiceExt; + + fn setup_router() -> Router { + let (status_tx, _) = tokio::sync::broadcast::channel::>(1); + Server::::add_routes(Router::new()).layer(Extension(Arc::new( + ServerState { + manager: Arc::new(MockManager::new()), + service: Arc::new(Service::new_mocked_prometheus(false)), + swap_status_update_tx: status_tx.clone(), + swap_infos: Fetcher { status_tx }, + }, + ))) + } + + #[tokio::test] + async fn test_swap_recovery() { + let res = setup_router() + .oneshot( + Request::builder() + .method(axum::http::Method::POST) + .uri("/v2/swap/recovery") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "xpub": "xpub661MyMwAqRbcGXPykvqCkK3sspTv2iwWTYpY9gBewku5Noj96ov1EqnKMDzGN9yPsncpRoUymJ7zpJ7HQiEtEC9Af2n3DmVu36TSV4oaiym" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + + let body = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!( + serde_json::from_slice::>(&body).unwrap(), + vec![], + ); + } + + #[tokio::test] + async fn test_swap_recovery_invalid_xpub() { + let res = setup_router() + .oneshot( + Request::builder() + .method(axum::http::Method::POST) + .uri("/v2/swap/recovery") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "xpub": "invalid" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let body = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!( + serde_json::from_slice::(&body).unwrap().error, + "Failed to deserialize the JSON body into the target type: xpub: invalid xpub: base58 encoding error at line 1 column 17" + ); + } +} diff --git a/boltzr/src/config.rs b/boltzr/src/config.rs index 29f9a2ac..44fd7ce6 100644 --- a/boltzr/src/config.rs +++ b/boltzr/src/config.rs @@ -52,6 +52,9 @@ pub struct GlobalConfig { #[serde(rename = "profilingEndpoint")] pub profiling_endpoint: Option, + #[serde(rename = "mnemonicpath")] + pub mnemonic_path: Option, + #[serde(rename = "mnemonicpathEvm")] pub mnemonic_path_evm: Option, @@ -77,16 +80,20 @@ pub fn parse_config(path: &str) -> Result> { let mut config = toml::from_str::(fs::read_to_string(path)?.as_ref())?; trace!("Read config: {:#}", serde_json::to_string_pretty(&config)?); + let default_mnemonic_path = Path::new(path) + .parent() + .unwrap() + .join("seed.dat") + .to_str() + .unwrap() + .to_string(); + + if config.mnemonic_path.is_none() { + config.mnemonic_path = Some(default_mnemonic_path.clone()); + } + if config.mnemonic_path_evm.is_none() { - config.mnemonic_path_evm = Some( - Path::new(path) - .parent() - .unwrap() - .join("seed.dat") - .to_str() - .unwrap() - .to_string(), - ); + config.mnemonic_path_evm = Some(default_mnemonic_path); } let data_dir = config.clone().sidecar.data_dir.unwrap_or( diff --git a/boltzr/src/currencies.rs b/boltzr/src/currencies.rs index 28b4479d..78b3bb69 100644 --- a/boltzr/src/currencies.rs +++ b/boltzr/src/currencies.rs @@ -2,11 +2,16 @@ use crate::chain::BaseClient; use crate::chain::chain_client::ChainClient; use crate::chain::elements_client::ElementsClient; use crate::config::{CurrencyConfig, LiquidConfig}; +use crate::db::helpers::keys::KeysHelper; use crate::lightning::cln::Cln; use crate::lightning::lnd::Lnd; use crate::wallet; use crate::wallet::Wallet; +use anyhow::anyhow; +use bip39::Mnemonic; use std::collections::HashMap; +use std::fs; +use std::str::FromStr; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::{debug, error, warn}; @@ -24,12 +29,20 @@ pub struct Currency { pub type Currencies = Arc>; -pub async fn connect_nodes( +pub async fn connect_nodes( cancellation_token: CancellationToken, + keys_helper: K, + mnemonic_path: Option, network: Option, currencies: Option>, liquid: Option, ) -> anyhow::Result { + let mnemonic = match mnemonic_path { + Some(path) => fs::read_to_string(path)?, + None => return Err(anyhow!("no mnemonic path")), + }; + let seed = Mnemonic::from_str(mnemonic.trim())?.to_seed(""); + let network = parse_network(network)?; let mut curs = HashMap::new(); @@ -39,11 +52,17 @@ pub async fn connect_nodes( for currency in currencies { debug!("Connecting to nodes of {}", currency.symbol); + let keys_info = keys_helper.get_for_symbol(¤cy.symbol)?; + curs.insert( currency.symbol.clone(), Currency { network, - wallet: Arc::new(wallet::Bitcoin::new(network)), + wallet: Arc::new(wallet::Bitcoin::new( + network, + &seed, + keys_info.derivationPath, + )?), chain: match currency.chain { Some(config) => { #[allow(clippy::manual_map)] @@ -91,13 +110,19 @@ pub async fn connect_nodes( crate::chain::elements_client::SYMBOL ); + let keys_info = keys_helper.get_for_symbol(crate::chain::elements_client::SYMBOL)?; + curs.insert( crate::chain::elements_client::SYMBOL.to_string(), Currency { network, cln: None, lnd: None, - wallet: Arc::new(wallet::Elements::new(network)), + wallet: Arc::new(wallet::Elements::new( + network, + &seed, + keys_info.derivationPath, + )?), #[allow(clippy::manual_map)] chain: match connect_client(ElementsClient::new(liquid.chain)).await { Some(client) => Some(Arc::new(Box::new(client))), diff --git a/boltzr/src/db/helpers/chain_swap.rs b/boltzr/src/db/helpers/chain_swap.rs index 685d5232..3d6fc04a 100644 --- a/boltzr/src/db/helpers/chain_swap.rs +++ b/boltzr/src/db/helpers/chain_swap.rs @@ -1,13 +1,21 @@ use crate::db::Pool; -use crate::db::helpers::{BoxedCondition, QueryResponse}; +use crate::db::helpers::{BoxedCondition, BoxedNullableCondition, QueryResponse}; use crate::db::models::{ChainSwap, ChainSwapData, ChainSwapInfo}; -use crate::db::schema::chainSwaps; -use diesel::{BelongingToDsl, GroupedBy, QueryDsl, RunQueryDsl, SelectableHelper}; +use crate::db::schema::{chainSwapData, chainSwaps}; +use diesel::{ + BelongingToDsl, ExpressionMethods, GroupedBy, QueryDsl, RunQueryDsl, SelectableHelper, +}; pub type ChainSwapCondition = BoxedCondition; +pub type ChainSwapDataNullableCondition = BoxedNullableCondition; + pub trait ChainSwapHelper { fn get_all(&self, condition: ChainSwapCondition) -> QueryResponse>; + fn get_by_data_nullable( + &self, + condition: ChainSwapDataNullableCondition, + ) -> QueryResponse>; } #[derive(Clone, Debug)] @@ -46,4 +54,43 @@ impl ChainSwapHelper for ChainSwapHelperDatabase { Ok(infos) } + + fn get_by_data_nullable( + &self, + condition: ChainSwapDataNullableCondition, + ) -> QueryResponse> { + let data = chainSwapData::dsl::chainSwapData + .select(ChainSwapData::as_select()) + .filter(condition) + .load(&mut self.pool.get()?)?; + + self.get_all(Box::new( + chainSwaps::dsl::id.eq_any(data.into_iter().map(|d| d.swapId).collect::>()), + )) + } +} + +#[cfg(test)] +pub mod test { + use super::*; + use mockall::mock; + + mock! { + pub ChainSwapHelper {} + + impl Clone for ChainSwapHelper { + fn clone(&self) -> Self; + } + + impl ChainSwapHelper for ChainSwapHelper { + fn get_all( + &self, + condition: ChainSwapCondition, + ) -> QueryResponse>; + fn get_by_data_nullable( + &self, + condition: ChainSwapDataNullableCondition, + ) -> QueryResponse>; + } + } } diff --git a/boltzr/src/db/helpers/keys.rs b/boltzr/src/db/helpers/keys.rs new file mode 100644 index 00000000..621e8098 --- /dev/null +++ b/boltzr/src/db/helpers/keys.rs @@ -0,0 +1,35 @@ +use crate::db::Pool; +use crate::db::helpers::QueryResponse; +use crate::db::models::Keys; +use crate::db::schema::keys; +use anyhow::anyhow; +use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, SelectableHelper}; + +pub trait KeysHelper { + fn get_for_symbol(&self, symbol: &str) -> QueryResponse; +} + +#[derive(Clone, Debug)] +pub struct KeysHelperDatabase { + pool: Pool, +} + +impl KeysHelperDatabase { + pub fn new(pool: Pool) -> Self { + Self { pool } + } +} + +impl KeysHelper for KeysHelperDatabase { + fn get_for_symbol(&self, symbol: &str) -> QueryResponse { + let keys = keys::dsl::keys + .select(Keys::as_select()) + .filter(keys::symbol.eq(symbol)) + .load(&mut self.pool.get()?)?; + if keys.is_empty() { + return Err(anyhow!("no key derivation path for {}", symbol)); + } + + Ok(keys[0].to_owned()) + } +} diff --git a/boltzr/src/db/helpers/mod.rs b/boltzr/src/db/helpers/mod.rs index 662d568e..0885a1e0 100644 --- a/boltzr/src/db/helpers/mod.rs +++ b/boltzr/src/db/helpers/mod.rs @@ -1,13 +1,15 @@ use diesel::BoxableExpression; use diesel::pg::Pg; -use diesel::sql_types::Bool; +use diesel::sql_types::{Bool, Nullable}; pub mod chain_swap; +pub mod keys; pub mod referral; pub mod reverse_swap; pub mod swap; pub mod web_hook; pub type BoxedCondition = Box>; +pub type BoxedNullableCondition = Box>>; pub type QueryResponse = anyhow::Result; diff --git a/boltzr/src/db/helpers/swap.rs b/boltzr/src/db/helpers/swap.rs index c46262d9..8dc70f8b 100644 --- a/boltzr/src/db/helpers/swap.rs +++ b/boltzr/src/db/helpers/swap.rs @@ -1,5 +1,5 @@ use crate::db::Pool; -use crate::db::helpers::{BoxedCondition, QueryResponse}; +use crate::db::helpers::{BoxedCondition, BoxedNullableCondition, QueryResponse}; use crate::db::models::Swap; use crate::db::schema::swaps; use crate::swap::SwapUpdate; @@ -7,9 +7,11 @@ use diesel::prelude::*; use diesel::{QueryDsl, RunQueryDsl, SelectableHelper, update}; pub type SwapCondition = BoxedCondition; +pub type SwapNullableCondition = BoxedNullableCondition; pub trait SwapHelper { fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; fn update_status( &self, id: &str, @@ -30,7 +32,14 @@ impl SwapHelperDatabase { } impl SwapHelper for SwapHelperDatabase { - fn get_all(&self, condition: BoxedCondition) -> QueryResponse> { + fn get_all(&self, condition: SwapCondition) -> QueryResponse> { + Ok(swaps::dsl::swaps + .select(Swap::as_select()) + .filter(condition) + .load(&mut self.pool.get()?)?) + } + + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse> { Ok(swaps::dsl::swaps .select(Swap::as_select()) .filter(condition) @@ -59,3 +68,28 @@ impl SwapHelper for SwapHelperDatabase { } } } + +#[cfg(test)] +pub mod test { + use super::*; + use mockall::mock; + + mock!( + pub SwapHelper {} + + impl Clone for SwapHelper { + fn clone(&self) -> Self; + } + + impl SwapHelper for SwapHelper { + fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; + fn update_status( + &self, + id: &str, + status: SwapUpdate, + failure_reason: Option, + ) -> QueryResponse; + } + ); +} diff --git a/boltzr/src/db/models/chain_swap.rs b/boltzr/src/db/models/chain_swap.rs index 695ee16c..f356d3d7 100644 --- a/boltzr/src/db/models/chain_swap.rs +++ b/boltzr/src/db/models/chain_swap.rs @@ -13,6 +13,7 @@ pub struct ChainSwap { pub pair: String, pub orderSide: i32, pub status: String, + pub createdAt: chrono::NaiveDateTime, } #[derive( @@ -34,6 +35,10 @@ pub struct ChainSwap { pub struct ChainSwapData { pub swapId: String, pub symbol: String, + pub keyIndex: Option, + pub theirPublicKey: Option, + pub swapTree: Option, + pub timeoutBlockHeight: i32, pub lockupAddress: String, pub transactionId: Option, pub transactionVout: Option, @@ -41,7 +46,7 @@ pub struct ChainSwapData { #[derive(Default, Clone, Debug)] pub struct ChainSwapInfo { - swap: ChainSwap, + pub swap: ChainSwap, sending_data: ChainSwapData, receiving_data: ChainSwapData, } @@ -202,6 +207,7 @@ mod test { pair: "L-BTC/BTC".to_string(), status: "swap.created".to_string(), orderSide: order_side.unwrap_or(OrderSide::Buy) as i32, + ..Default::default() }, vec![ ChainSwapData { @@ -210,6 +216,7 @@ mod test { swapId: id.to_string(), symbol: "BTC".to_string(), lockupAddress: "bc1".to_string(), + ..Default::default() }, ChainSwapData { transactionId: None, @@ -217,6 +224,7 @@ mod test { swapId: id.to_string(), symbol: "L-BTC".to_string(), lockupAddress: "lq1".to_string(), + ..Default::default() }, ], ) diff --git a/boltzr/src/db/models/keys.rs b/boltzr/src/db/models/keys.rs new file mode 100644 index 00000000..d790506c --- /dev/null +++ b/boltzr/src/db/models/keys.rs @@ -0,0 +1,8 @@ +use diesel::{AsChangeset, Insertable, Queryable, Selectable}; + +#[derive(Queryable, Selectable, Insertable, AsChangeset, PartialEq, Default, Clone, Debug)] +#[diesel(table_name = crate::db::schema::keys)] +#[allow(non_snake_case)] +pub struct Keys { + pub derivationPath: String, +} diff --git a/boltzr/src/db/models/mod.rs b/boltzr/src/db/models/mod.rs index cba18fcb..0ae99929 100644 --- a/boltzr/src/db/models/mod.rs +++ b/boltzr/src/db/models/mod.rs @@ -1,13 +1,18 @@ use crate::swap::SwapUpdate; +use serde::de::Visitor; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; use strum_macros::{Display, EnumString}; mod chain_swap; +mod keys; mod referral; mod reverse_swap; mod swap; mod web_hook; pub use chain_swap::*; +pub use keys::*; pub use referral::*; pub use reverse_swap::*; pub use swap::*; @@ -46,6 +51,50 @@ impl From for u64 { } } +impl Serialize for SwapType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + SwapType::Submarine => serializer.serialize_str("submarine"), + SwapType::Reverse => serializer.serialize_str("reverse"), + SwapType::Chain => serializer.serialize_str("chain"), + } + } +} + +impl<'de> Deserialize<'de> for SwapType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SwapTypeVisitor; + + impl Visitor<'_> for SwapTypeVisitor { + type Value = SwapType; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid swap type") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match value { + "submarine" => Ok(SwapType::Submarine), + "reverse" => Ok(SwapType::Reverse), + "chain" => Ok(SwapType::Chain), + _ => Err(E::custom(format!("invalid swap type: {}", value))), + } + } + } + + deserializer.deserialize_string(SwapTypeVisitor) + } +} + pub trait SomeSwap { fn kind(&self) -> SwapType; diff --git a/boltzr/src/db/models/swap.rs b/boltzr/src/db/models/swap.rs index f9812391..92119353 100644 --- a/boltzr/src/db/models/swap.rs +++ b/boltzr/src/db/models/swap.rs @@ -14,7 +14,13 @@ pub struct Swap { pub status: String, pub failureReason: Option, pub invoice: Option, + pub keyIndex: Option, + pub refundPublicKey: Option, + pub timeoutBlockHeight: i32, + pub redeemScript: Option, pub lockupAddress: String, + pub lockupTransactionId: Option, + pub lockupTransactionVout: Option, pub createdAt: chrono::NaiveDateTime, } diff --git a/boltzr/src/db/schema.rs b/boltzr/src/db/schema.rs index 5dcc703d..1cb20477 100644 --- a/boltzr/src/db/schema.rs +++ b/boltzr/src/db/schema.rs @@ -21,14 +21,21 @@ diesel::table! { #[allow(non_snake_case)] swaps (id) { id -> Text, + version -> Integer, referral -> Nullable, pair -> Text, orderSide -> Integer, status -> Text, failureReason -> Nullable, invoice -> Nullable, + keyIndex -> Nullable, + refundPublicKey -> Nullable, + timeoutBlockHeight -> Integer, + redeemScript -> Nullable, lockupAddress -> Text, - createdAt -> Timestamp, + lockupTransactionId -> Nullable, + lockupTransactionVout -> Nullable, + createdAt -> Timestamptz, } } @@ -51,6 +58,7 @@ diesel::table! { pair -> Text, orderSide -> Integer, status -> Text, + createdAt -> Timestamptz, } } @@ -59,11 +67,24 @@ diesel::table! { chainSwapData (swapId, symbol) { swapId -> Text, symbol -> Text, + keyIndex -> Nullable, + theirPublicKey -> Nullable, + swapTree -> Nullable, + timeoutBlockHeight -> Integer, lockupAddress -> Text, transactionId -> Nullable, transactionVout -> Nullable, } } +diesel::table! { + #[allow(non_snake_case)] + keys (symbol) { + symbol -> Text, + derivationPath -> Text, + highestUsedIndex -> Integer, + } +} + joinable!(chainSwapData -> chainSwaps (swapId)); allow_tables_to_appear_in_same_query!(chainSwaps, chainSwapData); diff --git a/boltzr/src/evm/manager.rs b/boltzr/src/evm/manager.rs index 29ef6ab3..f551056c 100644 --- a/boltzr/src/evm/manager.rs +++ b/boltzr/src/evm/manager.rs @@ -27,7 +27,7 @@ impl Manager { debug!("Read mnemonic"); let signer = MnemonicBuilder::::default() - .phrase(mnemonic.strip_suffix("\n").unwrap_or(mnemonic.as_str())) + .phrase(mnemonic.trim()) .index(0)? .build()?; diff --git a/boltzr/src/grpc/server.rs b/boltzr/src/grpc/server.rs index 407a811d..f3d0e493 100644 --- a/boltzr/src/grpc/server.rs +++ b/boltzr/src/grpc/server.rs @@ -157,7 +157,6 @@ where mod server_test { use crate::api::ws; use crate::api::ws::types::SwapStatus; - use crate::cache::Redis; use crate::chain::utils::Transaction; use crate::currencies::Currency; use crate::db::helpers::QueryResponse; @@ -168,7 +167,6 @@ mod server_test { use crate::grpc::service::boltzr::GetInfoRequest; use crate::grpc::service::boltzr::boltz_r_client::BoltzRClient; use crate::notifications::commands::Commands; - use crate::service::Service; use crate::swap::manager::SwapManager; use crate::tracing_setup::ReloadHandler; use crate::webhook::caller; @@ -250,12 +248,7 @@ mod server_test { disable_ssl: Some(true), }, ReloadHandler::new(), - Arc::new(Service::new::( - Arc::new(HashMap::new()), - None, - None, - None, - )), + Arc::new(crate::service::Service::new_mocked_prometheus(false)), Arc::new(make_mock_manager()), status_tx, Box::new(make_mock_hook_helper()), @@ -383,12 +376,7 @@ mod server_test { disable_ssl: Some(false), }, ReloadHandler::new(), - Arc::new(Service::new::( - Arc::new(HashMap::new()), - None, - None, - None, - )), + Arc::new(crate::service::Service::new_mocked_prometheus(false)), Arc::new(make_mock_manager()), status_tx, Box::new(make_mock_hook_helper()), diff --git a/boltzr/src/grpc/service.rs b/boltzr/src/grpc/service.rs index 93a30f6e..fe550592 100644 --- a/boltzr/src/grpc/service.rs +++ b/boltzr/src/grpc/service.rs @@ -708,8 +708,12 @@ mod test { use crate::api::ws; use crate::cache::Redis; use crate::db::helpers::QueryResponse; + use crate::db::helpers::chain_swap::{ + ChainSwapCondition, ChainSwapDataNullableCondition, ChainSwapHelper, + }; + use crate::db::helpers::swap::{SwapCondition, SwapHelper, SwapNullableCondition}; use crate::db::helpers::web_hook::WebHookHelper; - use crate::db::models::{WebHook, WebHookState}; + use crate::db::models::{ChainSwapInfo, Swap, WebHook, WebHookState}; use crate::evm::RefundSigner; use crate::grpc::service::BoltzService; use crate::grpc::service::boltzr::boltz_r_server::BoltzR; @@ -722,6 +726,7 @@ mod test { use crate::grpc::status_fetcher::StatusFetcher; use crate::notifications::commands::Commands; use crate::service::Service; + use crate::swap::SwapUpdate; use crate::swap::manager::test::MockManager; use crate::tracing_setup::ReloadHandler; use crate::webhook::caller::{Caller, Config}; @@ -736,6 +741,44 @@ mod test { use tokio_util::sync::CancellationToken; use tonic::{Code, Request}; + mock! { + SwapHelper {} + + impl Clone for SwapHelper { + fn clone(&self) -> Self; + } + + impl SwapHelper for SwapHelper { + fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; + fn update_status( + &self, + id: &str, + status: SwapUpdate, + failure_reason: Option, + ) -> QueryResponse; + } + } + + mock! { + ChainSwapHelper {} + + impl Clone for ChainSwapHelper { + fn clone(&self) -> Self; + } + + impl ChainSwapHelper for ChainSwapHelper { + fn get_all( + &self, + condition: ChainSwapCondition, + ) -> QueryResponse>; + fn get_by_data_nullable( + &self, + condition: ChainSwapDataNullableCondition, + ) -> QueryResponse>; + } + } + mock! { WebHookHelper {} @@ -1063,6 +1106,8 @@ mod test { BoltzService::new( ReloadHandler::new(), Arc::new(Service::new::( + Arc::new(MockSwapHelper::new()), + Arc::new(MockChainSwapHelper::new()), Arc::new(HashMap::new()), None, None, diff --git a/boltzr/src/main.rs b/boltzr/src/main.rs index 9bab0b2f..3a2f05c8 100644 --- a/boltzr/src/main.rs +++ b/boltzr/src/main.rs @@ -1,5 +1,8 @@ use crate::config::parse_config; use crate::currencies::connect_nodes; +use crate::db::helpers::chain_swap::ChainSwapHelperDatabase; +use crate::db::helpers::keys::KeysHelperDatabase; +use crate::db::helpers::swap::SwapHelperDatabase; use crate::service::Service; use crate::swap::manager::Manager; use api::ws; @@ -142,6 +145,8 @@ async fn main() { let currencies = match connect_nodes( cancellation_token.clone(), + KeysHelperDatabase::new(db_pool.clone()), + config.mnemonic_path, config.network, config.currencies, config.liquid, @@ -156,6 +161,8 @@ async fn main() { }; let service = Arc::new(Service::new( + Arc::new(SwapHelperDatabase::new(db_pool.clone())), + Arc::new(ChainSwapHelperDatabase::new(db_pool.clone())), currencies.clone(), config.marking, config.historical, diff --git a/boltzr/src/service/lightning_info.rs b/boltzr/src/service/lightning_info.rs index c623d451..c492090b 100644 --- a/boltzr/src/service/lightning_info.rs +++ b/boltzr/src/service/lightning_info.rs @@ -209,7 +209,9 @@ mod test { use crate::service::lightning_info::{ClnLightningInfo, LightningInfo}; use crate::wallet::{Bitcoin, Network}; use alloy::hex; + use bip39::Mnemonic; use std::collections::HashMap; + use std::str::FromStr; use std::sync::Arc; async fn get_currencies() -> Currencies { @@ -217,7 +219,18 @@ mod test { "BTC".to_string(), Currency { network: Network::Regtest, - wallet: Arc::new(Bitcoin::new(Network::Regtest)), + wallet: Arc::new( + Bitcoin::new( + Network::Regtest, + &Mnemonic::from_str( + "test test test test test test test test test test test junk", + ) + .unwrap() + .to_seed(""), + "m/0/0".to_string(), + ) + .unwrap(), + ), chain: Some(Arc::new(Box::new( crate::chain::chain_client::test::get_client(), ))), diff --git a/boltzr/src/service/mod.rs b/boltzr/src/service/mod.rs index 4ca81c97..640eac44 100644 --- a/boltzr/src/service/mod.rs +++ b/boltzr/src/service/mod.rs @@ -1,7 +1,12 @@ use crate::cache::Cache; +use crate::currencies::Currencies; +use crate::db::helpers::chain_swap::ChainSwapHelper; +use crate::db::helpers::swap::SwapHelper; use crate::service::country_codes::CountryCodes; +use crate::service::lightning_info::{ClnLightningInfo, LightningInfo}; use crate::service::pair_stats::PairStatsFetcher; use crate::service::prometheus::{CachedPrometheusClient, RawPrometheusClient}; +use crate::service::recovery::SwapRecovery; use anyhow::Result; use std::fmt::Debug; use std::sync::Arc; @@ -11,14 +16,14 @@ mod country_codes; mod lightning_info; mod pair_stats; mod prometheus; +mod recovery; -use crate::currencies::Currencies; -use crate::service::lightning_info::{ClnLightningInfo, LightningInfo}; pub use country_codes::MarkingsConfig; pub use lightning_info::InfoFetchError; pub use pair_stats::HistoricalConfig; pub struct Service { + pub swap_recovery: SwapRecovery, pub country_codes: CountryCodes, pub lightning_info: Box, pub pair_stats: Option, @@ -26,12 +31,15 @@ pub struct Service { impl Service { pub fn new( + swap_helper: Arc, + chain_swap_helper: Arc, currencies: Currencies, markings_config: Option, historical_config: Option, cache: Option, ) -> Self { Self { + swap_recovery: SwapRecovery::new(swap_helper, chain_swap_helper, currencies.clone()), country_codes: CountryCodes::new(markings_config), lightning_info: Box::new(ClnLightningInfo::new(cache.clone(), currencies)), pair_stats: if let Some(config) = historical_config { @@ -66,14 +74,74 @@ impl Service { pub mod test { use super::*; use crate::cache::Redis; + use crate::db::helpers::QueryResponse; + use crate::db::helpers::chain_swap::{ChainSwapCondition, ChainSwapDataNullableCondition}; + use crate::db::helpers::swap::{SwapCondition, SwapHelper, SwapNullableCondition}; + use crate::db::models::{ChainSwapInfo, Swap}; use crate::service::prometheus::test::MockPrometheus; + use crate::swap::SwapUpdate; + use mockall::mock; use std::collections::HashMap; pub use pair_stats::PairStats; + pub use recovery::RecoverableSwap; + + mock! { + SwapHelper {} + + impl Clone for SwapHelper { + fn clone(&self) -> Self; + } + + impl SwapHelper for SwapHelper { + fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; + fn update_status( + &self, + id: &str, + status: SwapUpdate, + failure_reason: Option, + ) -> QueryResponse; + } + } + + mock! { + ChainSwapHelper {} + + impl Clone for ChainSwapHelper { + fn clone(&self) -> Self; + } + + impl ChainSwapHelper for ChainSwapHelper { + fn get_all( + &self, + condition: ChainSwapCondition, + ) -> QueryResponse>; + fn get_by_data_nullable( + &self, + condition: ChainSwapDataNullableCondition, + ) -> QueryResponse>; + } + } impl Service { pub fn new_mocked_prometheus(with_pair_stats: bool) -> Self { + let mut swap_helper = MockSwapHelper::new(); + swap_helper + .expect_get_all_nullable() + .returning(|_| Ok(vec![])); + + let mut chain_swap_helper = MockChainSwapHelper::new(); + chain_swap_helper + .expect_get_by_data_nullable() + .returning(|_| Ok(vec![])); + Self { + swap_recovery: SwapRecovery::new( + Arc::new(swap_helper), + Arc::new(chain_swap_helper), + Arc::new(HashMap::new()), + ), lightning_info: Box::new(ClnLightningInfo::::new( None, Arc::new(HashMap::new()), diff --git a/boltzr/src/service/recovery.rs b/boltzr/src/service/recovery.rs new file mode 100644 index 00000000..b2c9af6f --- /dev/null +++ b/boltzr/src/service/recovery.rs @@ -0,0 +1,604 @@ +use crate::currencies::Currencies; +use crate::db::helpers::chain_swap::ChainSwapHelper; +use crate::db::helpers::swap::SwapHelper; +use crate::db::models::{LightningSwap, SomeSwap, SwapType}; +use crate::wallet::Wallet; +use alloy::hex; +use anyhow::{Result, anyhow}; +use bitcoin::bip32::{DerivationPath, Xpub}; +use bitcoin::secp256k1; +use bitcoin::secp256k1::Secp256k1; +use diesel::{BoolExpressionMethods, ExpressionMethods}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use tracing::{debug, instrument, trace}; + +const DERIVATION_PATH: &str = "m/44/0/0/0"; +const GAP_LIMIT: u64 = 50; + +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] +pub struct TreeLeaf { + pub version: u64, + pub output: String, +} + +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] +pub struct SwapTree { + #[serde(rename = "claimLeaf")] + pub claim_leaf: TreeLeaf, + #[serde(rename = "refundLeaf")] + pub refund_leaf: TreeLeaf, + #[serde(rename = "covenantClaimLeaf", skip_serializing_if = "Option::is_none")] + pub covenant_claim_leaf: Option, +} + +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] +pub struct Transaction { + pub id: String, + pub vout: u64, +} + +#[derive(Serialize, Deserialize, PartialEq, Clone, Debug)] +pub struct RecoverableSwap { + pub id: String, + #[serde(rename = "type")] + pub kind: SwapType, + pub status: String, + pub symbol: String, + #[serde(rename = "keyIndex")] + pub key_index: u64, + #[serde(rename = "timeoutBlockHeight")] + pub timeout_block_height: u64, + #[serde(rename = "serverPublicKey")] + pub server_public_key: String, + #[serde(rename = "blindingKey", skip_serializing_if = "Option::is_none")] + pub blinding_key: Option, + pub tree: SwapTree, + #[serde(rename = "lockupAddress")] + pub lockup_address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction: Option, + #[serde(rename = "createdAt")] + pub created_at: u64, +} + +// TODO: database indexes + +pub struct SwapRecovery { + currencies: Currencies, + swap_helper: Arc, + chain_swap_helper: Arc, +} + +impl SwapRecovery { + pub fn new( + swap_helper: Arc, + chain_swap_helper: Arc, + currencies: Currencies, + ) -> SwapRecovery { + Self { + currencies, + swap_helper, + chain_swap_helper, + } + } + + #[instrument(name = "SwapRecovery::recover_xpub", skip_all)] + pub fn recover_xpub(&self, xpub: &Xpub) -> Result> { + debug!( + "Scanning for recoverable swaps for {}", + xpub.identifier().to_string() + ); + + let secp = Secp256k1::default(); + + let mut recoverable = Vec::new(); + + for from in (0..).step_by(GAP_LIMIT as usize) { + let to = from + GAP_LIMIT; + + trace!( + "Scanning for recoverable swaps from key index {} to {} for {}", + from, + to, + xpub.identifier().to_string() + ); + + let keys_map = Self::derive_keys(&secp, xpub, from, to)?; + let keys = keys_map.keys().map(|k| Some(k.clone())).collect::>(); + + let swaps = self.swap_helper.get_all_nullable(Box::new( + crate::db::schema::swaps::dsl::version + // No legacy swaps + .gt(0) + .and(crate::db::schema::swaps::dsl::refundPublicKey.eq_any(keys.clone())), + ))?; + let chain_swaps = self.chain_swap_helper.get_by_data_nullable(Box::new( + crate::db::schema::chainSwapData::dsl::theirPublicKey.eq_any(keys), + ))?; + + if swaps.is_empty() && chain_swaps.is_empty() { + debug!( + "Scanned {} keys for recoverable swaps for {} and found {}", + to, + xpub.identifier().to_string(), + recoverable.len(), + ); + break; + } + + recoverable.append( + &mut swaps + .into_iter() + .map(|s| { + let wallet = &self + .currencies + .get(&s.chain_symbol()?) + .ok_or_else(|| anyhow!("no wallet for {}", s.id))? + .wallet; + + Ok(RecoverableSwap { + id: s.id.clone(), + kind: s.kind(), + symbol: s.chain_symbol()?, + key_index: Self::lookup_from_keys( + &keys_map, + s.refundPublicKey.clone(), + &s.id, + )?, + timeout_block_height: s.timeoutBlockHeight as u64, + server_public_key: Self::derive_our_public_key( + &secp, + wallet, + &s.id(), + s.keyIndex, + )?, + blinding_key: Self::derive_blinding_key( + wallet, + &s.id, + &s.chain_symbol()?, + &s.lockupAddress, + )?, + tree: Self::parse_tree( + s.redeemScript + .as_ref() + .ok_or_else(|| anyhow!("no swap tree for {}", s.id))?, + )?, + transaction: Self::transform_transaction( + s.lockupTransactionId, + s.lockupTransactionVout, + ), + status: s.status, + lockup_address: s.lockupAddress, + created_at: s.createdAt.and_utc().timestamp() as u64, + }) + }) + .collect::>>()?, + ); + recoverable.append( + &mut chain_swaps + .into_iter() + .filter(|s| { + if let Some(their_public_key) = &s.receiving().theirPublicKey { + return keys_map.contains_key(their_public_key); + } + + false + }) + .map(|s| { + let wallet = &self + .currencies + .get(&s.receiving().symbol) + .ok_or_else(|| anyhow!("no wallet for {}", s.id()))? + .wallet; + + Ok(RecoverableSwap { + id: s.id(), + kind: s.kind(), + symbol: s.receiving().symbol.clone(), + key_index: Self::lookup_from_keys( + &keys_map, + s.receiving().theirPublicKey.clone(), + &s.id(), + )?, + server_public_key: Self::derive_our_public_key( + &secp, + wallet, + &s.id(), + s.receiving().keyIndex, + )?, + timeout_block_height: s.receiving().timeoutBlockHeight as u64, + blinding_key: Self::derive_blinding_key( + wallet, + &s.id(), + &s.receiving().symbol, + &s.receiving().lockupAddress, + )?, + tree: Self::parse_tree( + s.receiving() + .swapTree + .as_ref() + .ok_or_else(|| anyhow!("no swap tree for {}", s.id()))?, + )?, + transaction: Self::transform_transaction( + s.receiving().transactionId.clone(), + s.receiving().transactionVout, + ), + lockup_address: s.receiving().lockupAddress.clone(), + status: s.swap.status, + created_at: s.swap.createdAt.and_utc().timestamp() as u64, + }) + }) + .collect::>>()?, + ); + } + + recoverable.sort_by(|a, b| a.key_index.cmp(&b.key_index)); + Ok(recoverable) + } + + fn transform_transaction( + transaction_id: Option, + vout: Option, + ) -> Option { + if let (Some(transaction_id), Some(vout)) = (transaction_id, vout) { + Some(Transaction { + id: transaction_id, + vout: vout as u64, + }) + } else { + None + } + } + + fn derive_our_public_key( + secp: &Secp256k1, + wallet: &Arc, + id: &str, + key_index: Option, + ) -> Result { + Ok(hex::encode( + wallet + .derive_keys(key_index.ok_or_else(|| anyhow!("no key index for {}", id))? as u64)? + .private_key + .public_key(secp) + .serialize(), + )) + } + + fn derive_blinding_key( + wallet: &Arc, + id: &str, + symbol: &str, + address: &str, + ) -> Result> { + if symbol != crate::chain::elements_client::SYMBOL { + return Ok(None); + } + + Ok(Some(hex::encode( + wallet + .derive_blinding_key(address) + .map_err(|e| anyhow!("deriving blinding key failed for {}: {}", id, e))?, + ))) + } + + fn lookup_from_keys(keys: &HashMap, key: Option, id: &str) -> Result { + Ok(*keys + .get(&key.ok_or_else(|| anyhow!("no public key for {}", id))?) + .ok_or_else(|| anyhow!("no key mapping for {}", id))?) + } + + fn derive_keys( + secp: &Secp256k1, + xpub: &Xpub, + start: u64, + end: u64, + ) -> Result> { + let mut map = HashMap::new(); + + for i in start..end { + let key = xpub + .derive_pub( + secp, + &DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH, i))?, + ) + .map(|derived| derived.public_key)?; + + map.insert(hex::encode(key.serialize()), i); + } + + Ok(map) + } + + fn parse_tree(tree: &str) -> Result { + serde_json::from_str(tree).map_err(|e| e.into()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::currencies::Currency; + use crate::db::helpers::chain_swap::test::MockChainSwapHelper; + use crate::db::helpers::swap::test::MockSwapHelper; + use crate::db::models::{ChainSwap, ChainSwapData, ChainSwapInfo, Swap}; + use crate::wallet::{Elements, Network}; + use rstest::rstest; + + fn get_liquid_wallet() -> Arc { + Arc::new( + Elements::new( + Network::Regtest, + &crate::wallet::test::get_seed(), + "m/0/1".to_string(), + ) + .unwrap(), + ) + } + + #[test] + fn test_recover_xpub() { + let tree = "{\"claimLeaf\":{\"version\":196,\"output\":\"a914617cc637679ded498738a09314294837227fbf938820ceb839aafaafdb6370781cb102567101f4ab628f54734792ede606fa8fd4f35fac\"},\"refundLeaf\":{\"version\":196,\"output\":\"2020103b104886a5180dd1be5146cceb12f19e59bdd63bca41470c91d94f317cdead02c527b1\"}}"; + + let swap = Swap { + id: "swap".to_string(), + pair: "L-BTC/BTC".to_string(), + orderSide: 1, + status: "invoice.failedToPay".to_string(), + keyIndex: Some(1), + timeoutBlockHeight: 321, + refundPublicKey: Some("025964821780625d20ba1af21a45b203a96dcc5986c75c2d43bdc873d224810b0c".to_string()), + lockupAddress: "el1qqwgersfg6zwpr0htqwg6rt7zwvz5ypec9q2zn2d2s526uevt4hdtyf8jqgtak7aummc7te0rj0ke4v7ygj60s7a07pe3nz6a6".to_string(), + redeemScript: Some(tree.to_string()), + lockupTransactionId: Some("some tx".to_string()), + lockupTransactionVout: Some(12), + createdAt: chrono::NaiveDateTime::from_str("2025-01-01T23:56:04").unwrap(), + ..Default::default() + }; + + let chain_swap = ChainSwapInfo::new( + ChainSwap { + id: "chain".to_string(), + pair: "L-BTC/BTC".to_string(), + orderSide: 1, + status: "transaction.failed".to_string(), + createdAt: chrono::NaiveDateTime::from_str("2025-01-01T23:57:21").unwrap(), + }, + vec![ChainSwapData { + swapId: "chain".to_string(), + symbol: "L-BTC".to_string(), + keyIndex: Some(123), + theirPublicKey: Some( + "02a21f37434b4f5b9e53c8401b75a078e5f6fb797c6d29feb8d9fbf980e6320b3b" + .to_string(), + ), + swapTree: Some(tree.to_string()), + timeoutBlockHeight: 13_211, + lockupAddress: "el1qqdg7adcqj6kqgz0fp3pyts0kmvgft07r38t3lqhspw7cjncahffay897ym8xmd9c20kc8yx90xt3n38f8wpygvnuc3d4cue6m".to_string(), + ..Default::default() + }, ChainSwapData { + swapId: "chain".to_string(), + symbol: "BTC".to_string(), + ..Default::default() + }], + ) + .unwrap(); + + let mut swap_helper = MockSwapHelper::new(); + { + let swap = swap.clone(); + swap_helper + .expect_get_all_nullable() + .returning(move |_| Ok(vec![swap.clone()])) + .times(1); + } + swap_helper + .expect_get_all_nullable() + .returning(|_| Ok(vec![])) + .times(1); + + let mut chain_helper = MockChainSwapHelper::new(); + { + let chain_swap = chain_swap.clone(); + chain_helper + .expect_get_by_data_nullable() + .returning(move |_| Ok(vec![chain_swap.clone()])) + .times(1); + } + chain_helper + .expect_get_by_data_nullable() + .returning(|_| Ok(vec![])) + .times(1); + + let recovery = SwapRecovery::new( + Arc::new(swap_helper), + Arc::new(chain_helper), + Arc::new(HashMap::from([( + crate::chain::elements_client::SYMBOL.to_string(), + Currency { + network: Network::Regtest, + wallet: get_liquid_wallet(), + chain: None, + cln: None, + lnd: None, + }, + )])), + ); + let xpub = Xpub::from_str("xpub661MyMwAqRbcGXPykvqCkK3sspTv2iwWTYpY9gBewku5Noj96ov1EqnKMDzGN9yPsncpRoUymJ7zpJ7HQiEtEC9Af2n3DmVu36TSV4oaiym").unwrap(); + let res = recovery.recover_xpub(&xpub).unwrap(); + assert_eq!(res.len(), 2); + assert_eq!( + res[0], + RecoverableSwap { + id: swap.id, + kind: SwapType::Submarine, + status: swap.status, + symbol: crate::chain::elements_client::SYMBOL.to_string(), + key_index: 0, + timeout_block_height: swap.timeoutBlockHeight as u64, + server_public_key: + "03f80e5650435fb598bb07257d50af378d4f7ddf8f2f78181f8b29abb0b05ecb47".to_string(), + blinding_key: Some( + "cf93ed8c71de3fff39a265898766ef327cf123e8eb7084fabaead2d6092de90d".to_string() + ), + tree: SwapRecovery::parse_tree(&swap.redeemScript.unwrap()).unwrap(), + lockup_address: swap.lockupAddress, + transaction: Some(Transaction { + id: swap.lockupTransactionId.unwrap(), + vout: swap.lockupTransactionVout.unwrap() as u64, + }), + created_at: 1735775764, + } + ); + assert_eq!( + res[1], + RecoverableSwap { + id: chain_swap.id(), + kind: SwapType::Chain, + timeout_block_height: chain_swap.receiving().timeoutBlockHeight as u64, + tree: SwapRecovery::parse_tree(&chain_swap.receiving().swapTree.clone().unwrap()) + .unwrap(), + lockup_address: chain_swap.receiving().lockupAddress.clone(), + status: chain_swap.swap.status, + symbol: crate::chain::elements_client::SYMBOL.to_string(), + key_index: 11, + server_public_key: + "02609b800f905a8bfba6763a5f0d9bdca4192648b006aeeb22598ea0b9004cf6c9".to_string(), + blinding_key: Some( + "fdf74d729d49d917bcc1befdc66922d6bf99af2e1cf49659299340962957916a".to_string() + ), + transaction: None, + created_at: 1735775841, + } + ); + } + + #[rstest] + #[case(Some("txid".to_string()), Some(23), Some(Transaction { id: "txid".to_string(), vout: 23 }))] + #[case(Some("txid".to_string()), None, None)] + #[case(None, Some(23), None)] + fn test_transform_transaction( + #[case] tx_id: Option, + #[case] vout: Option, + #[case] res: Option, + ) { + assert_eq!(SwapRecovery::transform_transaction(tx_id, vout), res); + } + + #[test] + fn test_derive_our_public_key() { + assert_eq!( + SwapRecovery::derive_our_public_key( + &Secp256k1::signing_only(), + &get_liquid_wallet(), + "", + Some(0) + ) + .unwrap(), + "0371ce2b829f0de3863be481d9d72fde7a11f780e070be73f35b5ddd4a878327f9" + ); + } + + #[test] + fn test_derive_our_public_key_no_key_index() { + let id = "id"; + assert_eq!( + SwapRecovery::derive_our_public_key( + &Secp256k1::signing_only(), + &get_liquid_wallet(), + id, + None + ) + .err() + .unwrap() + .to_string(), + format!("no key index for {}", id) + ); + } + + #[test] + fn test_derive_blinding_key() { + assert_eq!(SwapRecovery::derive_blinding_key( + &get_liquid_wallet(), + "", + crate::chain::elements_client::SYMBOL, + "el1pqt0dzt0mh2gxxvrezmzqexg0n66rkmd5997wn255wmfpqdegd2qyh284rq5v4h2vtj0ey3399k8d8v8qwsphj3qt4cf9zj08h0zqhraf0qcqltm5nfxq", + ).unwrap().unwrap(), "bd47a0bd2544c3d2e171a31cc769b8c2f7e5670f7cb14c06fe1dbf827b18e3cf"); + } + + #[test] + fn test_derive_blinding_key_non_liquid() { + assert!( + SwapRecovery::derive_blinding_key(&get_liquid_wallet(), "", "BTC", "") + .unwrap() + .is_none() + ); + } + + #[test] + fn test_lookup_from_keys() { + let key = "key"; + assert_eq!( + SwapRecovery::lookup_from_keys( + &HashMap::from([(key.to_string(), 21)]), + Some(key.to_string()), + "" + ) + .unwrap(), + 21 + ); + } + + #[test] + fn test_lookup_from_keys_no_key() { + let id = "adsf"; + assert_eq!( + SwapRecovery::lookup_from_keys(&HashMap::new(), None, id) + .err() + .unwrap() + .to_string(), + format!("no public key for {}", id) + ); + } + + #[test] + fn test_lookup_from_keys_no_mapping() { + let id = "adsf"; + assert_eq!( + SwapRecovery::lookup_from_keys(&HashMap::new(), Some("".to_string()), id) + .err() + .unwrap() + .to_string(), + format!("no key mapping for {}", id) + ); + } + + #[test] + fn test_derive_keys() { + let xpub = Xpub::from_str("xpub661MyMwAqRbcGXPykvqCkK3sspTv2iwWTYpY9gBewku5Noj96ov1EqnKMDzGN9yPsncpRoUymJ7zpJ7HQiEtEC9Af2n3DmVu36TSV4oaiym").unwrap(); + let keys = + SwapRecovery::derive_keys(&Secp256k1::verification_only(), &xpub, 0, 10).unwrap(); + + assert_eq!(keys.len(), 10); + assert_eq!( + *keys + .get("025964821780625d20ba1af21a45b203a96dcc5986c75c2d43bdc873d224810b0c") + .unwrap(), + 0 + ); + assert_eq!( + *keys + .get("03f00262509d6c450463b293dedf06ccb472d160325debdb97fae58b05f0863cf0") + .unwrap(), + 1 + ); + } + + #[test] + fn test_parse_tree() { + assert!(SwapRecovery::parse_tree("{\"claimLeaf\":{\"version\":192,\"output\":\"82012088a91433ca578b1dde9cb32e4b6a2c05fe74520911b66e8820884ff511cc5061a90f07e553de127095df5d438b2bda23db4159c5f32df5e1f9ac\"},\"refundLeaf\":{\"version\":192,\"output\":\"205bbdfe5d1bf863f65c5271d4cd6621c44048b89e80aa79301fe671d98bed598aad026001b1\"}}").err().is_none()); + } +} diff --git a/boltzr/src/swap/expiration/custom_expiry.rs b/boltzr/src/swap/expiration/custom_expiry.rs index 2a486b42..dd0da2d6 100644 --- a/boltzr/src/swap/expiration/custom_expiry.rs +++ b/boltzr/src/swap/expiration/custom_expiry.rs @@ -118,7 +118,7 @@ mod test { use super::*; use crate::db::helpers::QueryResponse; use crate::db::helpers::referral::{ReferralCondition, ReferralHelper}; - use crate::db::helpers::swap::{SwapCondition, SwapHelper}; + use crate::db::helpers::swap::{SwapCondition, SwapHelper, SwapNullableCondition}; use crate::db::models::Swap; use mockall::{mock, predicate}; @@ -131,6 +131,7 @@ mod test { impl SwapHelper for SwapHelper { fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; fn update_status( &self, id: &str, diff --git a/boltzr/src/swap/expiration/invoice_expiry.rs b/boltzr/src/swap/expiration/invoice_expiry.rs index 18cffed0..486fcab4 100644 --- a/boltzr/src/swap/expiration/invoice_expiry.rs +++ b/boltzr/src/swap/expiration/invoice_expiry.rs @@ -112,12 +112,14 @@ mod test { use crate::api::ws::types::SwapStatus; use crate::currencies::{Currencies, Currency}; use crate::db::helpers::QueryResponse; - use crate::db::helpers::swap::{SwapCondition, SwapHelper}; + use crate::db::helpers::swap::{SwapCondition, SwapHelper, SwapNullableCondition}; use crate::db::models::Swap; use crate::swap::SwapUpdate; use crate::wallet::{Bitcoin, Network}; + use bip39::Mnemonic; use mockall::{mock, predicate}; use std::collections::HashMap; + use std::str::FromStr; use std::sync::{Arc, OnceLock}; mock! { @@ -129,6 +131,7 @@ mod test { impl SwapHelper for SwapHelper { fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; fn update_status( &self, id: &str, @@ -146,7 +149,18 @@ mod test { String::from("BTC"), Currency { network: Network::Regtest, - wallet: Arc::new(Bitcoin::new(Network::Regtest)), + wallet: Arc::new( + Bitcoin::new( + Network::Regtest, + &Mnemonic::from_str( + "test test test test test test test test test test test junk", + ) + .unwrap() + .to_seed(""), + "m/0/0".to_string(), + ) + .unwrap(), + ), chain: Some(Arc::new(Box::new( crate::chain::chain_client::test::get_client(), ))), diff --git a/boltzr/src/swap/filters.rs b/boltzr/src/swap/filters.rs index 37b02c31..31ee1bda 100644 --- a/boltzr/src/swap/filters.rs +++ b/boltzr/src/swap/filters.rs @@ -210,9 +210,11 @@ mod test { use crate::chain::utils::Outpoint; use crate::currencies::{Currencies, Currency}; use crate::db::helpers::QueryResponse; - use crate::db::helpers::chain_swap::{ChainSwapCondition, ChainSwapHelper}; + use crate::db::helpers::chain_swap::{ + ChainSwapCondition, ChainSwapDataNullableCondition, ChainSwapHelper, + }; use crate::db::helpers::reverse_swap::{ReverseSwapCondition, ReverseSwapHelper}; - use crate::db::helpers::swap::{SwapCondition, SwapHelper}; + use crate::db::helpers::swap::{SwapCondition, SwapHelper, SwapNullableCondition}; use crate::db::models::{ChainSwap, ChainSwapData, ChainSwapInfo, ReverseSwap, Swap}; use crate::swap::SwapUpdate; use crate::swap::filters::{ @@ -220,8 +222,10 @@ mod test { }; use crate::wallet::{Bitcoin, Elements, Network, Wallet}; use alloy::hex; + use bip39::Mnemonic; use mockall::mock; use std::collections::HashMap; + use std::str::FromStr; use std::sync::{Arc, OnceLock}; mock! { @@ -233,6 +237,7 @@ mod test { impl SwapHelper for SwapHelper { fn get_all(&self, condition: SwapCondition) -> QueryResponse>; + fn get_all_nullable(&self, condition: SwapNullableCondition) -> QueryResponse>; fn update_status( &self, id: &str, @@ -269,9 +274,19 @@ mod test { &self, condition: ChainSwapCondition, ) -> QueryResponse>; + fn get_by_data_nullable( + &self, + condition: ChainSwapDataNullableCondition, + ) -> QueryResponse>; } } + fn get_seed() -> [u8; 64] { + Mnemonic::from_str("test test test test test test test test test test test junk") + .unwrap() + .to_seed("") + } + fn get_currencies() -> Currencies { static CURRENCIES: OnceLock = OnceLock::new(); CURRENCIES @@ -281,7 +296,10 @@ mod test { String::from("BTC"), Currency { network: Network::Regtest, - wallet: Arc::new(Bitcoin::new(Network::Regtest)), + wallet: Arc::new( + Bitcoin::new(Network::Regtest, &get_seed(), "m/0/0".to_string()) + .unwrap(), + ), chain: Some(Arc::new(Box::new( crate::chain::chain_client::test::get_client(), ))), @@ -293,7 +311,10 @@ mod test { String::from("LTC"), Currency { network: Network::Regtest, - wallet: Arc::new(Bitcoin::new(Network::Regtest)), + wallet: Arc::new( + Bitcoin::new(Network::Regtest, &get_seed(), "m/0/1".to_string()) + .unwrap(), + ), chain: None, cln: None, lnd: None, @@ -303,7 +324,10 @@ mod test { String::from("L-BTC"), Currency { network: Network::Regtest, - wallet: Arc::new(Elements::new(Network::Regtest)), + wallet: Arc::new( + Elements::new(Network::Regtest, &get_seed(), "m/0/2".to_string()) + .unwrap(), + ), chain: Some(Arc::new(Box::new( crate::chain::elements_client::test::get_client().0, ))), @@ -363,7 +387,8 @@ mod test { assert_eq!(outputs.len(), 1); assert!( outputs.contains( - &Bitcoin::new(Network::Regtest) + &Bitcoin::new(Network::Regtest, &get_seed(), "m/0/0".to_string()) + .unwrap() .decode_address(address_bitcoin) .unwrap() ) @@ -374,7 +399,8 @@ mod test { assert_eq!(outputs.len(), 1); assert!( outputs.contains( - &Elements::new(Network::Regtest) + &Elements::new(Network::Regtest, &get_seed(), "m/0/2".to_string()) + .unwrap() .decode_address(address_elements) .unwrap() ) @@ -500,7 +526,8 @@ mod test { assert_eq!(outputs.len(), 1); assert!( outputs.contains( - &Bitcoin::new(Network::Regtest) + &Bitcoin::new(Network::Regtest, &get_seed(), "m/0/0".to_string()) + .unwrap() .decode_address(address) .unwrap() ) @@ -539,7 +566,8 @@ mod test { #[test] fn test_decode_script() { - let wallet: Arc = Arc::new(Bitcoin::new(Network::Regtest)); + let wallet: Arc = + Arc::new(Bitcoin::new(Network::Regtest, &get_seed(), "m/0/0".to_string()).unwrap()); let swap = Swap { id: "id".to_string(), ..Default::default() @@ -554,7 +582,8 @@ mod test { #[test] fn test_decode_script_invalid() { - let wallet: Arc = Arc::new(Bitcoin::new(Network::Regtest)); + let wallet: Arc = + Arc::new(Bitcoin::new(Network::Regtest, &get_seed(), "m/0/0".to_string()).unwrap()); let swap = Swap { id: "id".to_string(), ..Default::default() diff --git a/boltzr/src/wallet/bitcoin.rs b/boltzr/src/wallet/bitcoin.rs index d1bad99a..baa9b30f 100644 --- a/boltzr/src/wallet/bitcoin.rs +++ b/boltzr/src/wallet/bitcoin.rs @@ -1,43 +1,70 @@ +use crate::wallet::keys::Keys; use crate::wallet::{Network, Wallet}; +use anyhow::{Result, anyhow}; +use bitcoin::bip32::Xpriv; use std::str::FromStr; pub struct Bitcoin { network: bitcoin::Network, + keys: Keys, } impl Bitcoin { - pub fn new(network: Network) -> Self { - Self { + pub fn new(network: Network, seed: &[u8; 64], path: String) -> Result { + Ok(Self { network: match network { Network::Mainnet => bitcoin::Network::Bitcoin, Network::Testnet => bitcoin::Network::Testnet, Network::Regtest => bitcoin::Network::Regtest, }, - } + keys: Keys::new(seed, path)?, + }) } } impl Wallet for Bitcoin { - fn decode_address(&self, address: &str) -> anyhow::Result> { + fn decode_address(&self, address: &str) -> Result> { let dec = bitcoin::address::Address::from_str(address)?; Ok(match dec.require_network(self.network) { Ok(address) => address.script_pubkey().into_bytes(), Err(_) => return Err(anyhow::anyhow!("invalid network")), }) } + + fn derive_keys(&self, index: u64) -> Result { + self.keys.derive_key(index) + } + + fn derive_blinding_key(&self, _address: &str) -> Result> { + Err(anyhow!("not implemented for bitcoin")) + } } #[cfg(test)] mod test { use crate::wallet::{Bitcoin, Network, Wallet}; + use alloy::hex; + use bip39::Mnemonic; + use bitcoin::secp256k1::Secp256k1; use rstest::*; + use std::str::FromStr; + + fn get_seed() -> ([u8; 64], String) { + ( + Mnemonic::from_str("test test test test test test test test test test test junk") + .unwrap() + .to_seed(""), + "m/0/0".to_string(), + ) + } #[rstest] #[case::mainnet(Network::Mainnet, bitcoin::Network::Bitcoin)] #[case::testnet(Network::Testnet, bitcoin::Network::Testnet)] #[case::regtest(Network::Regtest, bitcoin::Network::Regtest)] fn test_new(#[case] network: Network, #[case] expected: bitcoin::Network) { - let wallet = Bitcoin::new(network); + let (seed, path) = get_seed(); + let wallet = Bitcoin::new(network, &seed, path).unwrap(); assert_eq!(wallet.network, expected); } @@ -50,22 +77,57 @@ mod test { #[case::regtest_nested(Network::Regtest, "2N68eeJDUkhB2anQg6NkmNJJAZYZb9k3qjn")] #[case::regtest_legacy(Network::Regtest, "mwc8xtF856a1wGKNPd6cf1DLkSmc7NtcNb")] fn test_decode_address(#[case] network: Network, #[case] address: &str) { - let wallet = Bitcoin::new(network); + let (seed, path) = get_seed(); + let wallet = Bitcoin::new(network, &seed, path).unwrap(); assert!(wallet.decode_address(address).is_ok()); } #[test] fn test_decode_address_invalid() { - let result = Bitcoin::new(Network::Regtest).decode_address("invalid"); + let (seed, path) = get_seed(); + let result = Bitcoin::new(Network::Regtest, &seed, path) + .unwrap() + .decode_address("invalid"); assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "base58 error"); } #[test] fn test_decode_address_invalid_network() { - let result = Bitcoin::new(Network::Testnet) + let (seed, path) = get_seed(); + let result = Bitcoin::new(Network::Testnet, &seed, path) + .unwrap() .decode_address("bcrt1pvz6uhg0r5pthsa7d99udl3xct8q03e497cnuj0hn88fl3ph72tns65hlem"); assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "invalid network"); } + + #[test] + fn test_derive_keys() { + let (seed, path) = get_seed(); + let result = Bitcoin::new(Network::Testnet, &seed, path) + .unwrap() + .derive_keys(0) + .unwrap(); + assert_eq!( + hex::encode( + result + .private_key + .public_key(&Secp256k1::signing_only()) + .serialize() + ), + "034f9213a05b414189ea7edd4466cbce31ab052b03d6f9824e208287841a034bfc" + ); + } + + #[test] + fn test_derive_blinding_key() { + let (seed, path) = get_seed(); + let err = Bitcoin::new(Network::Testnet, &seed, path) + .unwrap() + .derive_blinding_key("") + .err() + .unwrap(); + assert_eq!(err.to_string(), "not implemented for bitcoin"); + } } diff --git a/boltzr/src/wallet/elements.rs b/boltzr/src/wallet/elements.rs index f9a0fce0..00fe5132 100644 --- a/boltzr/src/wallet/elements.rs +++ b/boltzr/src/wallet/elements.rs @@ -1,45 +1,89 @@ +use crate::wallet::keys::Keys; use crate::wallet::{Network, Wallet}; +use anyhow::Result; +use bitcoin::bip32::Xpriv; use elements::pset::serialize::Serialize; +use elements_miniscript::slip77; +use lightning::util::ser::Writeable; use std::str::FromStr; pub struct Elements { network: elements::AddressParams, + keys: Keys, + + slip77: slip77::MasterBlindingKey, } impl Elements { - pub fn new(network: Network) -> Self { - Self { + pub fn new(network: Network, seed: &[u8; 64], path: String) -> Result { + Ok(Self { network: match network { Network::Mainnet => elements::AddressParams::LIQUID, Network::Testnet => elements::AddressParams::LIQUID_TESTNET, Network::Regtest => elements::AddressParams::ELEMENTS, }, - } + keys: Keys::new(seed, path)?, + slip77: slip77::MasterBlindingKey::from_seed(seed), + }) } -} -impl Wallet for Elements { - fn decode_address(&self, address: &str) -> anyhow::Result> { + fn decode_address_inner(&self, address: &str) -> Result { let dec = elements::Address::from_str(address)?; if *dec.params != self.network { return Err(anyhow::anyhow!("invalid network")); } - Ok(dec.script_pubkey().serialize()) + Ok(dec) + } +} + +impl Wallet for Elements { + fn decode_address(&self, address: &str) -> Result> { + Ok(self + .decode_address_inner(address)? + .script_pubkey() + .serialize()) + } + + fn derive_keys(&self, index: u64) -> Result { + self.keys.derive_key(index) + } + + fn derive_blinding_key(&self, address: &str) -> Result> { + let address = self.decode_address_inner(address)?; + + Ok(self + .slip77 + .blinding_private_key(&address.script_pubkey()) + .encode()) } } #[cfg(test)] mod test { use crate::wallet::{Elements, Network, Wallet}; + use alloy::hex; + use bip39::Mnemonic; + use bitcoin::key::Secp256k1; use rstest::*; + use std::str::FromStr; + + fn get_seed() -> ([u8; 64], String) { + ( + Mnemonic::from_str("test test test test test test test test test test test junk") + .unwrap() + .to_seed(""), + "m/0/1".to_string(), + ) + } #[rstest] #[case::mainnet(Network::Mainnet, elements::AddressParams::LIQUID)] #[case::testnet(Network::Testnet, elements::AddressParams::LIQUID_TESTNET)] #[case::regtest(Network::Regtest, elements::AddressParams::ELEMENTS)] fn test_new(#[case] network: Network, #[case] expected: elements::AddressParams) { - let wallet = Elements::new(network); + let (seed, path) = get_seed(); + let wallet = Elements::new(network, &seed, path).unwrap(); assert_eq!(wallet.network, expected); } @@ -71,22 +115,59 @@ mod test { )] #[case::regtest_legacy_unconfidential(Network::Regtest, "2dfnSPrvvTtUmDnW6AdnyeoMYkuNiMRgxQe")] fn test_decode_address(#[case] network: Network, #[case] address: &str) { - let wallet = Elements::new(network); + let (seed, path) = get_seed(); + let wallet = Elements::new(network, &seed, path).unwrap(); assert!(wallet.decode_address(address).is_ok()); } #[test] fn test_decode_address_invalid() { - let result = Elements::new(Network::Regtest).decode_address("invalid"); + let (seed, path) = get_seed(); + let result = Elements::new(Network::Regtest, &seed, path) + .unwrap() + .decode_address("invalid"); assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "base58 error: decode"); } #[test] fn test_decode_address_invalid_network() { - let result = - Elements::new(Network::Testnet).decode_address("2dfnSPrvvTtUmDnW6AdnyeoMYkuNiMRgxQe"); + let (seed, path) = get_seed(); + let result = Elements::new(Network::Testnet, &seed, path) + .unwrap() + .decode_address("2dfnSPrvvTtUmDnW6AdnyeoMYkuNiMRgxQe"); assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "invalid network"); } + + #[test] + fn test_derive_keys() { + let (seed, path) = get_seed(); + let result = Elements::new(Network::Testnet, &seed, path) + .unwrap() + .derive_keys(0) + .unwrap(); + assert_eq!( + hex::encode( + result + .private_key + .public_key(&Secp256k1::signing_only()) + .serialize() + ), + "0371ce2b829f0de3863be481d9d72fde7a11f780e070be73f35b5ddd4a878327f9" + ); + } + + #[test] + fn test_derive_blinding_key() { + let (seed, path) = get_seed(); + let key = Elements::new(Network::Regtest, &seed, path) + .unwrap() + .derive_blinding_key("el1pqt0dzt0mh2gxxvrezmzqexg0n66rkmd5997wn255wmfpqdegd2qyh284rq5v4h2vtj0ey3399k8d8v8qwsphj3qt4cf9zj08h0zqhraf0qcqltm5nfxq") + .unwrap(); + assert_eq!( + hex::encode(key), + "bd47a0bd2544c3d2e171a31cc769b8c2f7e5670f7cb14c06fe1dbf827b18e3cf" + ); + } } diff --git a/boltzr/src/wallet/keys.rs b/boltzr/src/wallet/keys.rs new file mode 100644 index 00000000..e99835d4 --- /dev/null +++ b/boltzr/src/wallet/keys.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use bitcoin::bip32::{DerivationPath, Xpriv}; +use bitcoin::key::Secp256k1; +use bitcoin::{NetworkKind, secp256k1}; +use std::str::FromStr; + +pub struct Keys { + secp: Secp256k1, + xpriv: Xpriv, + path: String, +} + +impl Keys { + pub fn new(seed: &[u8; 64], path: String) -> Result { + Ok(Self { + path, + secp: Secp256k1::signing_only(), + xpriv: Xpriv::new_master(NetworkKind::Main, seed)?, + }) + } + + pub fn derive_key(&self, index: u64) -> Result { + Ok(self.xpriv.derive_priv( + &self.secp, + &DerivationPath::from_str(&format!("{}/{}", self.path, index))?, + )?) + } +} + +#[cfg(test)] +pub mod test { + use crate::wallet::keys::Keys; + use alloy::hex; + use bip39::Mnemonic; + use rstest::rstest; + use std::str::FromStr; + + pub fn get_seed() -> [u8; 64] { + Mnemonic::from_str("test test test test test test test test test test test junk") + .unwrap() + .to_seed("") + } + + #[rstest] + #[case( + "m/0/0", + 0, + "034f9213a05b414189ea7edd4466cbce31ab052b03d6f9824e208287841a034bfc" + )] + #[case( + "m/0/0", + 1, + "03defe74e5f8393f9c48d9c9fb0bf49a883adac25269890bb1d2d7c41af619f2d5" + )] + #[case( + "m/0/1", + 0, + "0371ce2b829f0de3863be481d9d72fde7a11f780e070be73f35b5ddd4a878327f9" + )] + #[case( + "m/0/1", + 1, + "03f80e5650435fb598bb07257d50af378d4f7ddf8f2f78181f8b29abb0b05ecb47" + )] + fn test_derive_key(#[case] path: String, #[case] index: u64, #[case] expected: &str) { + let keys = Keys::new(&get_seed(), path).unwrap(); + let key = keys.derive_key(index).unwrap(); + assert_eq!( + hex::encode(key.private_key.public_key(&keys.secp).serialize()), + expected + ); + } +} diff --git a/boltzr/src/wallet/mod.rs b/boltzr/src/wallet/mod.rs index 65cf7871..949e05e8 100644 --- a/boltzr/src/wallet/mod.rs +++ b/boltzr/src/wallet/mod.rs @@ -1,5 +1,9 @@ +use ::bitcoin::bip32::Xpriv; +use anyhow::Result; + mod bitcoin; mod elements; +mod keys; pub use bitcoin::*; pub use elements::*; @@ -12,5 +16,14 @@ pub enum Network { } pub trait Wallet { - fn decode_address(&self, address: &str) -> anyhow::Result>; + fn decode_address(&self, address: &str) -> Result>; + fn derive_keys(&self, index: u64) -> Result; + fn derive_blinding_key(&self, address: &str) -> Result>; +} + +#[cfg(test)] +pub mod test { + use super::*; + + pub use keys::test::get_seed; } diff --git a/docker/regtest/nginx.conf b/docker/regtest/nginx.conf new file mode 100644 index 00000000..adcb8ca8 --- /dev/null +++ b/docker/regtest/nginx.conf @@ -0,0 +1,49 @@ +server { + listen 9006; + + add_header 'Access-Control-Allow-Origin' '*' always; + add_header 'Access-Control-Allow-Headers' '*' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + + if ($request_method = OPTIONS) { + return 200; + } + + location /v2/ws { + proxy_pass http://localhost:9004/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /streamswapstatus { + proxy_pass http://localhost:9005; + proxy_http_version 1.1; + proxy_set_header Connection ''; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 24h; + proxy_set_header X-Forwarded-For $remote_addr; + keepalive_timeout 3600; + + chunked_transfer_encoding off; + } + + location ~ ^/v2/swap/[^/]+/stats/[^/]+/[^/]+$ { + proxy_pass http://localhost:9005; + } + + location ~ ^/v2/swap/recovery { + proxy_pass http://localhost:9005; + } + + location /v2/lightning/ { + proxy_pass http://localhost:9005; + } + + location / { + proxy_pass http://localhost:9001; + } +} \ No newline at end of file diff --git a/lib/Boltz.ts b/lib/Boltz.ts index f0c09c0f..40cda0b1 100644 --- a/lib/Boltz.ts +++ b/lib/Boltz.ts @@ -92,7 +92,6 @@ class Boltz { false, ); - Sidecar.start(this.logger, this.config); registerExitHandler(async () => { await this.grpcServer.close(); await this.db.close(); @@ -221,6 +220,10 @@ class Boltz { await this.db.migrate(this.currencies); await this.db.init(); + // To initialize the key provider before starting the sidecar + await this.walletManager.init(this.config.currencies); + + Sidecar.start(this.logger, this.config); await this.sidecar.connect(this.service.eventHandler, this.api.swapInfos); await this.sidecar.validateVersion(); await this.sidecar.start(); @@ -252,7 +255,6 @@ class Boltz { }), ); - await this.walletManager.init(this.config.currencies); await this.service.init(this.config.pairs); await this.service.swapManager.init( diff --git a/lib/wallet/WalletManager.ts b/lib/wallet/WalletManager.ts index 775b60ac..e6e5bc37 100644 --- a/lib/wallet/WalletManager.ts +++ b/lib/wallet/WalletManager.ts @@ -80,8 +80,10 @@ class WalletManager { this.logger.debug(`Loading EVM mnemonic from: ${mnemonicPathEvm}`); this.mnemonicEvm = this.loadMnemonic(mnemonicPathEvm); - this.masterNode = bip32.fromSeed(mnemonicToSeedSync(this.mnemonic)); - this.slip77 = slip77.fromSeed(this.mnemonic); + const seed = mnemonicToSeedSync(this.mnemonic); + this.masterNode = bip32.fromSeed(seed); + // TODO: this is a breaking change. not sure how to handle that + this.slip77 = slip77.fromSeed(seed); } public init = async (configCurrencies: CurrencyConfig[]): Promise => { diff --git a/package.json b/package.json index 5746aaa2..b588fc85 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,13 @@ "docker:solidity": "docker run -d --name anvil -p 8545:8545 ghcr.io/foundry-rs/foundry:nightly-95015894110734539c53ffad97cd64ca116fce5e \"anvil --host 0.0.0.0 --chain-id 33\"", "docker:solidity:deploy": "cd node_modules/boltz-core && cp -R ../@openzeppelin node_modules/ && npm run deploy:solidity", "docker:solidity:fund": "./bin/boltz-ethereum send 100000000000 && ./bin/boltz-ethereum send 1000000000 --token", - "docker:start": "npm run docker:regtest && npm run docker:solidity && npm run docker:solidity:deploy && npm run docker:solidity:fund", + "docker:nginx": "docker run -d --rm --name boltz-nginx --network host -v ./docker/regtest/nginx.conf:/etc/nginx/conf.d/nginx.conf nginx:stable", + "docker:start": "npm run docker:regtest && npm run docker:solidity && npm run docker:solidity:deploy && npm run docker:solidity:fund && npm run docker:nginx", "docker:cln:hold": "cd hold && cargo build && cp target/debug/hold ../docker/regtest/data/cln/plugins && docker exec regtest lightning-cli plugin start /root/.lightning/plugins/hold && docker exec regtest chmod -R 777 /root/.lightning/regtest/hold", "docker:cln:plugins": "npm run docker:cln:hold", "docker:dragonfly:start": "docker run -d -p 6379:6379 --ulimit memlock=-1 --name dragonfly docker.dragonflydb.io/dragonflydb/dragonfly --cache_mode --maxmemory 8g", "docker:dragonfly:stop": "docker stop dragonfly && docker rm dragonfly", - "docker:stop": "docker kill regtest && docker rm regtest && docker kill anvil && docker rm anvil", + "docker:stop": "docker kill regtest && docker rm regtest && docker kill anvil && docker rm anvil && docker stop boltz-nginx", "test": "npm run test:unit && npm run docker:start && npm run test:int && npm run docker:stop", "test:nodocker": "npm run test:unit && npm run test:int", "test:unit": "GRPC_NODE_VERBOSITY=NONE jest test/unit", diff --git a/swagger-spec.json b/swagger-spec.json index fa620a2a..a316a55a 100644 --- a/swagger-spec.json +++ b/swagger-spec.json @@ -3286,7 +3286,7 @@ "description": "Testnet" }, { - "url": "http://localhost:9001/v2", + "url": "http://localhost:9006/v2", "description": "Regtest" } ] diff --git a/swagger.js b/swagger.js index c88703e3..58d78684 100644 --- a/swagger.js +++ b/swagger.js @@ -27,7 +27,7 @@ specs.servers = [ description: 'Testnet', }, { - url: 'http://localhost:9001/v2', + url: 'http://localhost:9006/v2', description: 'Regtest', }, ]; diff --git a/test/integration/lightning/LndClient.spec.ts b/test/integration/lightning/LndClient.spec.ts index 0889e8fd..d5c42bab 100644 --- a/test/integration/lightning/LndClient.spec.ts +++ b/test/integration/lightning/LndClient.spec.ts @@ -21,7 +21,7 @@ import { sidecar, startSidecar } from '../sidecar/Utils'; describe('LndClient', () => { beforeAll(async () => { - startSidecar(); + await startSidecar(); await bitcoinClient.generate(1); await bitcoinLndClient.connect(false); diff --git a/test/integration/lightning/PendingPaymentTracker.spec.ts b/test/integration/lightning/PendingPaymentTracker.spec.ts index 413f0e91..a1360d0a 100644 --- a/test/integration/lightning/PendingPaymentTracker.spec.ts +++ b/test/integration/lightning/PendingPaymentTracker.spec.ts @@ -52,9 +52,10 @@ describe('PendingPaymentTracker', () => { ] as Currency[]; beforeAll(async () => { + await startSidecar(); + db = new Database(Logger.disabledLogger, Database.memoryDatabase); - startSidecar(); await Promise.all([ db.init(), clnClient.connect(), diff --git a/test/integration/lightning/cln/ClnClient.spec.ts b/test/integration/lightning/cln/ClnClient.spec.ts index 461dea30..46bdca8c 100644 --- a/test/integration/lightning/cln/ClnClient.spec.ts +++ b/test/integration/lightning/cln/ClnClient.spec.ts @@ -18,7 +18,7 @@ import { sidecar, startSidecar } from '../../sidecar/Utils'; describe('ClnClient', () => { beforeAll(async () => { - startSidecar(); + await startSidecar(); await bitcoinClient.generate(1); await bitcoinLndClient.connect(false); diff --git a/test/integration/service/TimeoutDeltaProvider.spec.ts b/test/integration/service/TimeoutDeltaProvider.spec.ts index 8dea259b..d4f246f2 100644 --- a/test/integration/service/TimeoutDeltaProvider.spec.ts +++ b/test/integration/service/TimeoutDeltaProvider.spec.ts @@ -68,7 +68,7 @@ describe('TimeoutDeltaProvider', () => { }; beforeAll(async () => { - startSidecar(); + await startSidecar(); await Promise.all([ sidecar.connect( diff --git a/test/integration/sidecar/DecodedInvoice.spec.ts b/test/integration/sidecar/DecodedInvoice.spec.ts index 2083b858..90f3f269 100644 --- a/test/integration/sidecar/DecodedInvoice.spec.ts +++ b/test/integration/sidecar/DecodedInvoice.spec.ts @@ -14,7 +14,7 @@ const bolt12Invoice = describe('DecodedInvoice', () => { beforeAll(async () => { - startSidecar(); + await startSidecar(); await sidecar.connect( { on: jest.fn(), removeAllListeners: jest.fn() } as any, {} as any, diff --git a/test/integration/sidecar/Sidecar.spec.ts b/test/integration/sidecar/Sidecar.spec.ts index ecc6e575..79e37808 100644 --- a/test/integration/sidecar/Sidecar.spec.ts +++ b/test/integration/sidecar/Sidecar.spec.ts @@ -9,7 +9,7 @@ describe('Sidecar', () => { const eventHandler = { on: jest.fn(), removeAllListeners: jest.fn() } as any; beforeAll(async () => { - startSidecar(); + await startSidecar(); await Promise.all([ sidecar.connect(eventHandler, {} as any, false), clnClient.connect(), diff --git a/test/integration/sidecar/Utils.ts b/test/integration/sidecar/Utils.ts index 3155e27f..1b524269 100644 --- a/test/integration/sidecar/Utils.ts +++ b/test/integration/sidecar/Utils.ts @@ -1,8 +1,33 @@ +import toml from '@iarna/toml'; +import fs from 'fs'; import path from 'path'; import Logger from '../../../lib/Logger'; +import Database from '../../../lib/db/Database'; +import KeyRepository from '../../../lib/db/repositories/KeyRepository'; import Sidecar from '../../../lib/sidecar/Sidecar'; -export const startSidecar = () => { +export const startSidecar = async () => { + const config = toml.parse( + fs.readFileSync(path.join(__dirname, 'config.toml'), { + encoding: 'utf-8', + }), + ); + const db = new Database( + Logger.disabledLogger, + undefined, + config.postgres as never, + ); + await db.init(); + if ((await KeyRepository.getKeyProvider('BTC')) === null) { + await KeyRepository.addKeyProvider({ + symbol: 'BTC', + derivationPath: 'm/0/0', + highestUsedIndex: 0, + }); + } + + await db.close(); + Sidecar.start(Logger.disabledLogger, { loglevel: 'error', sidecar: { diff --git a/test/integration/sidecar/config.toml b/test/integration/sidecar/config.toml index 60d393e5..0068d74e 100644 --- a/test/integration/sidecar/config.toml +++ b/test/integration/sidecar/config.toml @@ -6,17 +6,17 @@ username = "boltz" password = "boltz" [sidecar] - [sidecar.grpc] - host = "127.0.0.1" - port = 10_001 +[sidecar.grpc] +host = "127.0.0.1" +port = 10_001 - [sidecar.api] - host = "127.0.0.1" - port = 10_002 +[sidecar.api] +host = "127.0.0.1" +port = 10_002 - [sidecar.ws] - host = "127.0.0.1" - port = 10_003 +[sidecar.ws] +host = "127.0.0.1" +port = 10_003 [[currencies]] symbol = "BTC" diff --git a/test/integration/sidecar/seed.dat b/test/integration/sidecar/seed.dat new file mode 100644 index 00000000..66eeda6e --- /dev/null +++ b/test/integration/sidecar/seed.dat @@ -0,0 +1 @@ +test test test test test test test test test test test junk \ No newline at end of file diff --git a/test/integration/swap/UtxoNursery.spec.ts b/test/integration/swap/UtxoNursery.spec.ts index 24a98700..bdc9618e 100644 --- a/test/integration/swap/UtxoNursery.spec.ts +++ b/test/integration/swap/UtxoNursery.spec.ts @@ -49,7 +49,7 @@ import { import { sidecar, startSidecar } from '../sidecar/Utils'; describe('UtxoNursery', () => { - const db = new Database(Logger.disabledLogger, Database.memoryDatabase); + let db: Database; const currencies = [ { @@ -134,10 +134,11 @@ describe('UtxoNursery', () => { ); beforeAll(async () => { - startSidecar(); + await startSidecar(); await setup(); + db = new Database(Logger.disabledLogger, Database.memoryDatabase); await db.init(); await PairRepository.addPair({ base: elementsClient.symbol, From 315771f7fa12d107006eb8ed9a24380379d1455c Mon Sep 17 00:00:00 2001 From: michael1011 Date: Mon, 17 Feb 2025 22:27:26 +0100 Subject: [PATCH 2/2] refactor: migrate slip77 --- lib/Core.ts | 65 ++- lib/service/ElementsService.ts | 11 +- lib/service/TransactionFetcher.ts | 2 +- lib/swap/SwapManager.ts | 13 +- lib/swap/UtxoNursery.ts | 17 +- lib/wallet/Wallet.ts | 16 +- lib/wallet/WalletLiquid.ts | 57 ++- lib/wallet/WalletManager.ts | 14 +- test/integration/Core.spec.ts | 82 ++-- .../service/ElementsService.spec.ts | 79 ++-- .../service/TransactionFetcher.spec.ts | 11 +- .../cooperative/ChainSwapSigner.spec.ts | 10 +- .../cooperative/CoopSignerBase.spec.ts | 5 +- .../cooperative/DeferredClaimer.spec.ts | 2 +- test/unit/swap/SwapManager.spec.ts | 12 +- test/unit/swap/UtxoNursery.spec.ts | 2 +- test/unit/wallet/Wallet.spec.ts | 35 +- test/unit/wallet/WalletLiquid.spec.ts | 46 +- .../__snapshots__/WalletLiquid.spec.ts.snap | 397 +++++++++++++----- 19 files changed, 603 insertions(+), 273 deletions(-) diff --git a/lib/Core.ts b/lib/Core.ts index 9074cf64..f464aa5b 100644 --- a/lib/Core.ts +++ b/lib/Core.ts @@ -50,6 +50,7 @@ import { reverseBuffer, } from './Utils'; import { IChainClient } from './chain/ChainClient'; +import { SomeTransaction } from './chain/ZmqClient'; import { CurrencyType, SwapType, @@ -61,7 +62,7 @@ import ChainSwapData from './db/models/ChainSwapData'; import Swap from './db/models/Swap'; import SwapOutputType from './swap/SwapOutputType'; import Wallet from './wallet/Wallet'; -import WalletLiquid from './wallet/WalletLiquid'; +import WalletLiquid, { Slip77s } from './wallet/WalletLiquid'; import { Currency } from './wallet/WalletManager'; type UnblindedOutput = Omit & { @@ -159,14 +160,24 @@ export const getOutputValue = ( return output.value as number; } - const unblinded = unblindOutput( - wallet, - output as LiquidTxOutput, - (wallet as WalletLiquid).deriveBlindingKeyFromScript(output.script) - .privateKey!, + const walletKeys = (wallet as WalletLiquid).deriveBlindingKeyFromScript( + output.script, ); - return unblinded.isLbtc ? unblinded.value : 0; + for (const key of [walletKeys.new, walletKeys.legacy] + .map((k) => k.privateKey) + .filter((k) => k !== undefined)) { + try { + const unblinded = unblindOutput(wallet, output as LiquidTxOutput, key); + + return unblinded.isLbtc ? unblinded.value : 0; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + /* empty */ + } + } + + return 0; }; export const constructClaimDetails = ( @@ -230,7 +241,8 @@ export const constructClaimTransaction = ( claimDetails: ClaimDetails[] | LiquidClaimDetails[], destinationAddress: string, feePerVbyte: number, -) => { + blindingKeyType: keyof Slip77s = 'new', +): SomeTransaction => { const span = Tracing.tracer.startSpan('constructClaimTransaction', { kind: SpanKind.INTERNAL, attributes: { @@ -257,6 +269,7 @@ export const constructClaimTransaction = ( const liquidDetails = populateBlindingKeys( walletLiquid, claimDetails as LiquidClaimDetails[], + blindingKeyType, ); const decodedAddress = liquidAddress.fromConfidential(destinationAddress); @@ -274,6 +287,18 @@ export const constructClaimTransaction = ( walletLiquid.supportsDiscountCT, ); }); + } catch (e) { + if (blindingKeyType === 'new') { + return constructClaimTransaction( + wallet, + claimDetails, + destinationAddress, + feePerVbyte, + 'legacy', + ); + } + + throw e; } finally { span.end(); } @@ -285,7 +310,8 @@ export const constructRefundTransaction = ( destinationAddress: string, timeoutBlockHeight: number, feePerVbyte: number, -) => { + blindingKeyType: keyof Slip77s = 'new', +): SomeTransaction => { const span = Tracing.tracer.startSpan('constructRefundTransaction', { kind: SpanKind.INTERNAL, attributes: { @@ -313,6 +339,7 @@ export const constructRefundTransaction = ( const liquidDetails = populateBlindingKeys( walletLiquid, refundDetails as LiquidRefundDetails[], + blindingKeyType, ); const decodedAddress = liquidAddress.fromConfidential(destinationAddress); @@ -331,6 +358,19 @@ export const constructRefundTransaction = ( walletLiquid.supportsDiscountCT, ); }); + } catch (e) { + if (blindingKeyType === 'new') { + return constructRefundTransaction( + wallet, + refundDetails, + destinationAddress, + timeoutBlockHeight, + feePerVbyte, + 'legacy', + ); + } + + throw e; } finally { span.end(); } @@ -413,11 +453,12 @@ const populateBlindingKeys = < >( wallet: WalletLiquid, utxos: T[], + blindingKeyType: keyof Slip77s, ): T[] => { for (const utxo of utxos) { - utxo.blindingPrivateKey = wallet.deriveBlindingKeyFromScript( - utxo.script, - ).privateKey!; + utxo.blindingPrivateKey = wallet.deriveBlindingKeyFromScript(utxo.script)[ + blindingKeyType + ].privateKey!; } return utxos; diff --git a/lib/service/ElementsService.ts b/lib/service/ElementsService.ts index 648f53ca..94962c9a 100644 --- a/lib/service/ElementsService.ts +++ b/lib/service/ElementsService.ts @@ -27,16 +27,19 @@ class ElementsService { return await Promise.all( tx.outs.map(async (out) => { if (out.rangeProof !== undefined && out.rangeProof.length > 0) { + const walletKeys = wallet.deriveBlindingKeyFromScript(out.script)!; const keys = [ - wallet.deriveBlindingKeyFromScript(out.script).privateKey!, + walletKeys.new.privateKey, + walletKeys.legacy.privateKey, getHexBuffer( await chainClient.dumpBlindingKey( - wallet.encodeAddress(out.script, false), + // Does not matter which one we take because we don't blind + wallet.encodeAddress(out.script, false).new, ), ), ]; - for (const blindingKey of keys) { + for (const blindingKey of keys.filter((k) => k !== undefined)) { try { return unblindOutput(wallet, out, blindingKey); // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -56,7 +59,7 @@ class ElementsService { const { publicKey, privateKey } = wallet.deriveBlindingKeyFromScript( wallet.decodeAddress(address), - ); + ).new; return { publicKey: publicKey!, privateKey: privateKey!, diff --git a/lib/service/TransactionFetcher.ts b/lib/service/TransactionFetcher.ts index 0edf8d76..20ae783d 100644 --- a/lib/service/TransactionFetcher.ts +++ b/lib/service/TransactionFetcher.ts @@ -193,7 +193,7 @@ class TransactionFetcher { ? transaction.outs // Filter Liquid fee outputs .filter((out) => out.script.length > 0) - .map((out) => wallet.encodeAddress(out.script)) + .flatMap((out) => Object.values(wallet.encodeAddress(out.script))) // The wallet returns empty strings for addresses it cannot encode .filter((addr) => addr !== '') : []; diff --git a/lib/swap/SwapManager.ts b/lib/swap/SwapManager.ts index e0fab8ce..337b166d 100644 --- a/lib/swap/SwapManager.ts +++ b/lib/swap/SwapManager.ts @@ -416,14 +416,14 @@ class SwapManager { } } - result.address = receivingCurrency.wallet.encodeAddress(outputScript); + result.address = receivingCurrency.wallet.encodeAddress(outputScript).new; receivingCurrency.chainClient!.addOutputFilter(outputScript); if (receivingCurrency.type === CurrencyType.Liquid) { result.blindingKey = getHexString( ( receivingCurrency.wallet as WalletLiquid - ).deriveBlindingKeyFromScript(outputScript).privateKey!, + ).deriveBlindingKeyFromScript(outputScript).new.privateKey!, ); } @@ -872,13 +872,14 @@ class SwapManager { } } - result.lockupAddress = sendingCurrency.wallet.encodeAddress(outputScript); + result.lockupAddress = + sendingCurrency.wallet.encodeAddress(outputScript).new; if (sendingCurrency.type === CurrencyType.Liquid) { result.blindingKey = getHexString( (sendingCurrency.wallet as WalletLiquid).deriveBlindingKeyFromScript( outputScript, - ).privateKey!, + ).new.privateKey!, ); } @@ -1066,13 +1067,13 @@ class SwapManager { currency.chainClient!.addOutputFilter(outputScript); } - res.lockupAddress = currency.wallet.encodeAddress(outputScript); + res.lockupAddress = currency.wallet.encodeAddress(outputScript).new; if (currency.type === CurrencyType.Liquid) { blindingKey = getHexString( (currency.wallet as WalletLiquid).deriveBlindingKeyFromScript( outputScript, - ).privateKey!, + ).new.privateKey!, ); } } else { diff --git a/lib/swap/UtxoNursery.ts b/lib/swap/UtxoNursery.ts index dd1a3ef7..deb355eb 100644 --- a/lib/swap/UtxoNursery.ts +++ b/lib/swap/UtxoNursery.ts @@ -198,7 +198,9 @@ class UtxoNursery extends TypedEventEmitter<{ chainClient, wallet, ); - if (prevAddresses.some(this.blocks.isBlocked)) { + if ( + prevAddresses.flatMap((a) => Object.values(a)).some(this.blocks.isBlocked) + ) { chainClient.removeOutputFilter(swapOutput.script); this.emit('chainSwap.lockup.failed', { swap, @@ -326,9 +328,14 @@ class UtxoNursery extends TypedEventEmitter<{ await this.lock.acquire(UtxoNursery.lockupLock, async () => { for (let vout = 0; vout < transaction.outs.length; vout += 1) { const output = transaction.outs[vout]; - const address = wallet.encodeAddress(output.script); + const encoded = wallet.encodeAddress(output.script); - await Promise.all([checkSwap(address), checkChainSwap(address)]); + await Promise.all( + [...new Set([encoded.new, encoded.legacy])].flatMap((a) => [ + checkSwap(a), + checkChainSwap(a), + ]), + ); } }); }; @@ -794,7 +801,9 @@ class UtxoNursery extends TypedEventEmitter<{ chainClient, wallet, ); - if (prevAddresses.some(this.blocks.isBlocked)) { + if ( + prevAddresses.flatMap((a) => Object.values(a)).some(this.blocks.isBlocked) + ) { this.emit('swap.lockup.failed', { swap: updatedSwap, reason: Errors.BLOCKED_ADDRESS().message, diff --git a/lib/wallet/Wallet.ts b/lib/wallet/Wallet.ts index 828f0cd3..d40ec3ac 100644 --- a/lib/wallet/Wallet.ts +++ b/lib/wallet/Wallet.ts @@ -5,6 +5,7 @@ import Logger from '../Logger'; import { CurrencyType } from '../consts/Enums'; import KeyRepository from '../db/repositories/KeyRepository'; import Errors from './Errors'; +import type { Slip77s } from './WalletLiquid'; import WalletProviderInterface, { BalancerFetcher, SentTransaction, @@ -86,17 +87,26 @@ class Wallet implements BalancerFetcher { * * @param outputScript the output script to encode */ - public encodeAddress = (outputScript: Buffer): string => { + public encodeAddress = ( + outputScript: Buffer, + ): Record => { if (this.network === undefined) { throw Errors.NOT_SUPPORTED_BY_WALLET(this.symbol, 'encodeAddress'); } try { - return fromOutputScript(this.type, outputScript, this.network); + const address = fromOutputScript(this.type, outputScript, this.network); + return { + new: address, + legacy: address, + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { // Ignore invalid addresses - return ''; + return { + new: '', + legacy: '', + }; } }; diff --git a/lib/wallet/WalletLiquid.ts b/lib/wallet/WalletLiquid.ts index 4b5a60fa..1356d18e 100644 --- a/lib/wallet/WalletLiquid.ts +++ b/lib/wallet/WalletLiquid.ts @@ -7,11 +7,16 @@ import { CurrencyType } from '../consts/Enums'; import Wallet from './Wallet'; import WalletProviderInterface from './providers/WalletProviderInterface'; +export type Slip77s = { + new: Slip77Interface; + legacy: Slip77Interface; +}; + class WalletLiquid extends Wallet { constructor( logger: Logger, walletProvider: WalletProviderInterface, - private readonly slip77: Slip77Interface, + private readonly slip77s: Slip77s, network: Network, ) { super(logger, CurrencyType.Liquid, walletProvider, network); @@ -27,33 +32,63 @@ class WalletLiquid extends Wallet { ); } - public deriveBlindingKeyFromScript = (outputScript: Buffer) => { - return this.slip77.derive(outputScript); + public deriveBlindingKeyFromScript = ( + outputScript: Buffer, + ): Record => { + return { + new: this.slip77s.new.derive(outputScript), + legacy: this.slip77s.legacy.derive(outputScript), + }; }; public override encodeAddress = ( outputScript: Buffer, shouldBlind = true, - ): string => { + ): Record => { try { // Fee output of Liquid if (outputScript.length == 0) { - return ''; + return { + new: '', + legacy: '', + }; + } + + if (!shouldBlind) { + const res = this.getPaymentFunc(outputScript)({ + output: outputScript, + network: this.network as networks.Network, + }); + return { + new: res.address!, + legacy: res.address!, + }; } - const res = this.getPaymentFunc(outputScript)({ + const walletKeys = this.deriveBlindingKeyFromScript(outputScript); + + const resNew = this.getPaymentFunc(outputScript)({ + output: outputScript, + network: this.network as networks.Network, + blindkey: walletKeys.new.publicKey, + }); + const resLegacy = this.getPaymentFunc(outputScript)({ output: outputScript, network: this.network as networks.Network, - blindkey: shouldBlind - ? this.deriveBlindingKeyFromScript(outputScript).publicKey! - : undefined, + blindkey: walletKeys.legacy.publicKey, }); - return shouldBlind ? res.confidentialAddress! : res.address!; + return { + new: resNew.confidentialAddress!, + legacy: resLegacy.confidentialAddress!, + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { // Ignore invalid addresses - return ''; + return { + new: '', + legacy: '', + }; } }; diff --git a/lib/wallet/WalletManager.ts b/lib/wallet/WalletManager.ts index e6e5bc37..34ec8f6b 100644 --- a/lib/wallet/WalletManager.ts +++ b/lib/wallet/WalletManager.ts @@ -5,7 +5,7 @@ import { Provider } from 'ethers'; import fs from 'fs'; import { IElementsClient } from 'lib/chain/ElementsClient'; import type { Network as LiquidNetwork } from 'liquidjs-lib/src/networks'; -import { SLIP77Factory, Slip77Interface } from 'slip77'; +import { SLIP77Factory } from 'slip77'; import * as ecc from 'tiny-secp256k1'; import { CurrencyConfig } from '../Config'; import Logger from '../Logger'; @@ -18,7 +18,7 @@ import LndClient from '../lightning/LndClient'; import ClnClient from '../lightning/cln/ClnClient'; import Errors from './Errors'; import Wallet from './Wallet'; -import WalletLiquid from './WalletLiquid'; +import WalletLiquid, { Slip77s } from './WalletLiquid'; import EthereumManager from './ethereum/EthereumManager'; import CoreWalletProvider from './providers/CoreWalletProvider'; import ElementsWalletProvider from './providers/ElementsWalletProvider'; @@ -62,7 +62,7 @@ class WalletManager { private readonly mnemonic: string; private readonly mnemonicEvm: string; - private readonly slip77: Slip77Interface; + private readonly slip77s: Slip77s; private readonly masterNode: BIP32Interface; private readonly derivationPath = 'm/0'; @@ -82,8 +82,10 @@ class WalletManager { const seed = mnemonicToSeedSync(this.mnemonic); this.masterNode = bip32.fromSeed(seed); - // TODO: this is a breaking change. not sure how to handle that - this.slip77 = slip77.fromSeed(seed); + this.slip77s = { + new: slip77.fromSeed(seed), + legacy: slip77.fromSeed(this.mnemonic), + }; } public init = async (configCurrencies: CurrencyConfig[]): Promise => { @@ -168,7 +170,7 @@ class WalletManager { : new WalletLiquid( this.logger, walletProvider, - this.slip77, + this.slip77s, currency.network! as LiquidNetwork, ); diff --git a/test/integration/Core.spec.ts b/test/integration/Core.spec.ts index 5145224a..4a559974 100644 --- a/test/integration/Core.spec.ts +++ b/test/integration/Core.spec.ts @@ -89,7 +89,10 @@ describe('Core', () => { walletLiquid = new WalletLiquid( Logger.disabledLogger, new ElementsWalletProvider(Logger.disabledLogger, elementsClient), - slip77.fromSeed(generateMnemonic()), + { + new: slip77.fromSeed(mnemonicToSeedSync(generateMnemonic())), + legacy: slip77.fromSeed(generateMnemonic()), + }, networks.regtest, ); initWallet(walletLiquid); @@ -171,44 +174,51 @@ describe('Core', () => { walletLiquid['network'] = networks.regtest; }); - test('should get output value of blinded Liquid transactions', async () => { - const script = toOutputScript( - CurrencyType.Liquid, - await walletLiquid.getAddress(''), - walletLiquid.network!, - ); + test.each` + blindingType + ${'new'} + ${'legacy'} + `( + 'should get output value of blinded Liquid transactions blinded with type $blindingType', + async ({ blindingType }) => { + const script = toOutputScript( + CurrencyType.Liquid, + await walletLiquid.getAddress(''), + walletLiquid.network!, + ); - const outputAmount = 1245412; - const { transaction, vout } = await walletLiquid.sendToAddress( - walletLiquid.encodeAddress(script), - outputAmount, - undefined, - '', - ); + const outputAmount = 1245412; + const { transaction, vout } = await walletLiquid.sendToAddress( + walletLiquid.encodeAddress(script)[blindingType], + outputAmount, + undefined, + '', + ); - expect( - getOutputValue( - walletLiquid, - (transaction as LiquidTransaction).outs[vout!], - ), - ).toEqual(outputAmount); + expect( + getOutputValue( + walletLiquid, + (transaction as LiquidTransaction).outs[vout!], + ), + ).toEqual(outputAmount); - // Wrong asset hash + // Wrong asset hash - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - walletLiquid['network'] = networks.liquid; - expect( - getOutputValue( - walletLiquid, - (transaction as LiquidTransaction).outs[vout!], - ), - ).toEqual(0); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + walletLiquid['network'] = networks.liquid; + expect( + getOutputValue( + walletLiquid, + (transaction as LiquidTransaction).outs[vout!], + ), + ).toEqual(0); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - walletLiquid['network'] = networks.regtest; - }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + walletLiquid['network'] = networks.regtest; + }, + ); test('should construct legacy claim details', async () => { const preimage = randomBytes(32); @@ -226,7 +236,7 @@ describe('Core', () => { const tx = Transaction.fromHex( await bitcoinClient.getRawTransaction( await bitcoinClient.sendToAddress( - wallet.encodeAddress(outputScript), + wallet.encodeAddress(outputScript).new, 100_00, undefined, false, @@ -280,7 +290,7 @@ describe('Core', () => { const tx = Transaction.fromHex( await bitcoinClient.getRawTransaction( await bitcoinClient.sendToAddress( - wallet.encodeAddress(outputScript), + wallet.encodeAddress(outputScript).new, 100_00, undefined, false, diff --git a/test/integration/service/ElementsService.spec.ts b/test/integration/service/ElementsService.spec.ts index 6c7455d1..001a4e64 100644 --- a/test/integration/service/ElementsService.spec.ts +++ b/test/integration/service/ElementsService.spec.ts @@ -16,15 +16,16 @@ import { bitcoinClient, elementsClient } from '../Nodes'; jest.mock('../../../lib/db/repositories/ChainTipRepository'); -const slip77 = SLIP77Factory(ecc).fromSeed( - mnemonicToSeedSync(generateMnemonic()), -); +const slip77 = SLIP77Factory(ecc); describe('ElementsService', () => { const wallet = new WalletLiquid( Logger.disabledLogger, new ElementsWalletProvider(Logger.disabledLogger, elementsClient), - slip77, + { + new: slip77.fromSeed(mnemonicToSeedSync(generateMnemonic())), + legacy: slip77.fromSeed(generateMnemonic()), + }, networks.regtest, ); @@ -54,50 +55,60 @@ describe('ElementsService', () => { [bitcoinClient, elementsClient].map((client) => client.disconnect()); }); - test('should unblind outputs that were blinded by known keys', async () => { - const script = wallet.decodeAddress(await wallet.getAddress('')); - const address = wallet.encodeAddress(script); - const amount = 100_000; - - const tx = Transaction.fromHex( - await elementsClient.getRawTransaction( - await elementsClient.sendToAddress( - address, - amount, - undefined, - false, - '', + test.each` + blindingType + ${'new'} + ${'legacy'} + `( + 'should unblind outputs that were blinded by known keys of type $blindingType', + async ({ blindingType }) => { + const script = wallet.decodeAddress(await wallet.getAddress('')); + const address = wallet.encodeAddress(script)[blindingType]; + const amount = 100_000; + + const tx = Transaction.fromHex( + await elementsClient.getRawTransaction( + await elementsClient.sendToAddress( + address, + amount, + undefined, + false, + '', + ), ), - ), - ); + ); - const unblinded = await es.unblindOutputs(tx); + const unblinded = await es.unblindOutputs(tx); - expect(unblinded.every((out) => out.isLbtc)).toEqual(true); - expect(unblinded.every((out) => out.value > 0)).toEqual(true); - expect( - unblinded.every((out) => - out.asset.equals(getHexBuffer(networks.regtest.assetHash)), - ), - ).toEqual(true); + expect(unblinded.every((out) => out.isLbtc)).toEqual(true); + expect(unblinded.every((out) => out.value > 0)).toEqual(true); + expect( + unblinded.every((out) => + out.asset.equals(getHexBuffer(networks.regtest.assetHash)), + ), + ).toEqual(true); - const output = unblinded.find((out) => out.script.equals(script))!; - expect(output).not.toBeUndefined(); - expect(output.value).toEqual(amount); - }); + const output = unblinded.find((out) => out.script.equals(script))!; + expect(output).not.toBeUndefined(); + expect(output.value).toEqual(amount); + }, + ); test('should unblind outputs that were blinded by unknown keys', async () => { const secondWallet = new WalletLiquid( Logger.disabledLogger, new ElementsWalletProvider(Logger.disabledLogger, elementsClient), - SLIP77Factory(ecc).fromSeed(mnemonicToSeedSync(generateMnemonic())), + { + new: slip77.fromSeed(mnemonicToSeedSync(generateMnemonic())), + legacy: slip77.fromSeed(generateMnemonic()), + }, networks.regtest, ); const script = secondWallet.decodeAddress( await secondWallet.getAddress(''), ); - const address = secondWallet.encodeAddress(script); + const address = secondWallet.encodeAddress(script).new; const tx = Transaction.fromHex( await elementsClient.getRawTransaction( @@ -159,7 +170,7 @@ describe('ElementsService', () => { ${'el1qqwts7wxqn32rv9jdp86dr06q5y6keuxkjgdjmpn8x50jfepgspvwlw7hzcr33r9mf0yees30g2r8mrvpnn73qya0jqg3za6hu'} `('should derive blinding keys for $address', ({ address }) => { const script = wallet.decodeAddress(address); - const { publicKey, privateKey } = slip77.derive(script); + const { publicKey, privateKey } = wallet['slip77s'].new.derive(script); expect(es.deriveBlindingKeys(address)).toEqual({ publicKey, diff --git a/test/integration/service/TransactionFetcher.spec.ts b/test/integration/service/TransactionFetcher.spec.ts index 09c59a2e..53c7dec7 100644 --- a/test/integration/service/TransactionFetcher.spec.ts +++ b/test/integration/service/TransactionFetcher.spec.ts @@ -329,8 +329,10 @@ describe('TransactionFetcher', () => { let transaction: Transaction; const wallet = { - encodeAddress: (outputScript) => - address.fromOutputScript(outputScript, Networks.bitcoinRegtest), + encodeAddress: (outputScript) => ({ + new: address.fromOutputScript(outputScript, Networks.bitcoinRegtest), + legacy: address.fromOutputScript(outputScript, Networks.bitcoinRegtest), + }), } as Wallet; beforeAll(async () => { @@ -357,9 +359,10 @@ describe('TransactionFetcher', () => { expect(swaps.swapLockups).toEqual([{ id: 'swap' }]); expect(swaps.chainSwapLockups).toEqual([{ id: 'chain' }]); - const outputAddresses = transaction.outs.map((output) => + const outputAddresses = transaction.outs.flatMap((output) => [ address.fromOutputScript(output.script, Networks.bitcoinRegtest), - ); + address.fromOutputScript(output.script, Networks.bitcoinRegtest), + ]); expect(SwapRepository.getSwaps).toHaveBeenCalledTimes(1); expect(SwapRepository.getSwaps).toHaveBeenCalledWith({ diff --git a/test/integration/service/cooperative/ChainSwapSigner.spec.ts b/test/integration/service/cooperative/ChainSwapSigner.spec.ts index 650af499..645c1ec5 100644 --- a/test/integration/service/cooperative/ChainSwapSigner.spec.ts +++ b/test/integration/service/cooperative/ChainSwapSigner.spec.ts @@ -82,7 +82,10 @@ describe('ChainSwapSigner', () => { const liquidWallet = new WalletLiquid( Logger.disabledLogger, new ElementsWalletProvider(Logger.disabledLogger, elementsClient), - slip77.fromSeed(mnemonic), + { + legacy: slip77.fromSeed(mnemonic), + new: slip77.fromSeed(mnemonicToSeedSync(mnemonic)), + }, LiquidNetworks.liquidRegtest, ); @@ -162,7 +165,7 @@ describe('ChainSwapSigner', () => { currency.type, await currency.chainClient!.getRawTransaction( await currency.chainClient!.sendToAddress( - wallet.encodeAddress(lockupScript), + wallet.encodeAddress(lockupScript).new, 100_000, undefined, false, @@ -183,7 +186,8 @@ describe('ChainSwapSigner', () => { timeoutBlockHeight, blindingPrivateKey: currency.type === CurrencyType.Liquid - ? liquidWallet.deriveBlindingKeyFromScript(lockupScript).privateKey! + ? liquidWallet.deriveBlindingKeyFromScript(lockupScript).new + .privateKey! : undefined, }; }; diff --git a/test/integration/service/cooperative/CoopSignerBase.spec.ts b/test/integration/service/cooperative/CoopSignerBase.spec.ts index c28590be..08cb6fde 100644 --- a/test/integration/service/cooperative/CoopSignerBase.spec.ts +++ b/test/integration/service/cooperative/CoopSignerBase.spec.ts @@ -89,7 +89,7 @@ describe('CoopSignerBase', () => { ); const txId = await bitcoinClient.sendToAddress( - wallet.encodeAddress(p2trOutput(tweakedKey)), + wallet.encodeAddress(p2trOutput(tweakedKey)).new, 100_000, undefined, false, @@ -169,7 +169,8 @@ describe('CoopSignerBase', () => { expect(toClaim.cooperative!.transaction.outs).toHaveLength(1); expect( - wallet.encodeAddress(toClaim.cooperative!.transaction.outs[0].script), + wallet.encodeAddress(toClaim.cooperative!.transaction.outs[0].script) + .new, ).toEqual(toClaim.cooperative!.sweepAddress); }); diff --git a/test/integration/service/cooperative/DeferredClaimer.spec.ts b/test/integration/service/cooperative/DeferredClaimer.spec.ts index 099e782c..d7235578 100644 --- a/test/integration/service/cooperative/DeferredClaimer.spec.ts +++ b/test/integration/service/cooperative/DeferredClaimer.spec.ts @@ -178,7 +178,7 @@ describe('DeferredClaimer', () => { const tx = Transaction.fromHex( await bitcoinClient.getRawTransaction( await bitcoinClient.sendToAddress( - btcWallet.encodeAddress(p2trOutput(tweakedKey)), + btcWallet.encodeAddress(p2trOutput(tweakedKey)).new, 100_000, undefined, false, diff --git a/test/unit/swap/SwapManager.spec.ts b/test/unit/swap/SwapManager.spec.ts index fa59c2d7..2c865ab8 100644 --- a/test/unit/swap/SwapManager.spec.ts +++ b/test/unit/swap/SwapManager.spec.ts @@ -131,7 +131,9 @@ const mockDecodeAddress = jest.fn().mockImplementation((toDecode: string) => { const mockEncodeAddress = jest .fn() .mockImplementation((outputScript: Buffer) => { - return address.fromOutputScript(outputScript, Networks.bitcoinRegtest); + return { + new: address.fromOutputScript(outputScript, Networks.bitcoinRegtest), + }; }); jest.mock('../../../lib/wallet/Wallet', () => { @@ -160,9 +162,11 @@ const mockWallets = new Map([ addressLiquid.toOutputScript(address, LiquidNetworks.liquidRegtest), ), deriveBlindingKeyFromScript: jest.fn().mockReturnValue({ - privateKey: getHexBuffer( - '4e09bc9895ccef1eab4e2e67adcff67be2af26110ffb35f26592688c0e88dc76', - ), + new: { + privateKey: getHexBuffer( + '4e09bc9895ccef1eab4e2e67adcff67be2af26110ffb35f26592688c0e88dc76', + ), + }, }), } as any, ], diff --git a/test/unit/swap/UtxoNursery.spec.ts b/test/unit/swap/UtxoNursery.spec.ts index ce65eb61..ccf9a47a 100644 --- a/test/unit/swap/UtxoNursery.spec.ts +++ b/test/unit/swap/UtxoNursery.spec.ts @@ -113,7 +113,7 @@ const mockDecodeAddress = jest.fn().mockImplementation((toDecode: string) => { const encodeAddress = (script: Buffer) => address.fromOutputScript(script, Networks.bitcoinMainnet); const mockEncodeAddress = jest.fn().mockImplementation((script: Buffer) => { - return encodeAddress(script); + return { new: encodeAddress(script), legacy: encodeAddress(script) }; }); let mockGetKeysByIndexResult: ECPairInterface | undefined = undefined; diff --git a/test/unit/wallet/Wallet.spec.ts b/test/unit/wallet/Wallet.spec.ts index f1754a86..5447e136 100644 --- a/test/unit/wallet/Wallet.spec.ts +++ b/test/unit/wallet/Wallet.spec.ts @@ -101,7 +101,10 @@ describe('Wallet', () => { const walletLiquid = new WalletLiquid( Logger.disabledLogger, walletProvider, - slip77.fromSeed(mnemonic), + { + new: slip77.fromSeed(mnemonicToSeedSync(mnemonic)), + legacy: slip77.fromSeed(mnemonic), + }, networkLiquid.liquid, ); @@ -144,7 +147,9 @@ describe('Wallet', () => { }); test('should encode addresses', () => { - expect(wallet.encodeAddress(encodeOutput)).toEqual(encodedAddress); + const enc = wallet.encodeAddress(encodeOutput); + expect(enc.new).toEqual(encodedAddress); + expect(enc.legacy).toEqual(encodedAddress); }); test('should ignore OP_RETURN outputs', () => { @@ -153,7 +158,10 @@ describe('Wallet', () => { crypto.sha256(randomBytes(64)), ]); - expect(wallet.encodeAddress(outputScript)).toEqual(''); + expect(wallet.encodeAddress(outputScript)).toEqual({ + new: '', + legacy: '', + }); }); test('should ignore all invalid addresses', () => { @@ -171,7 +179,10 @@ describe('Wallet', () => { ]; for (const script of invalidScripts) { - expect(wallet.encodeAddress(script)).toEqual(''); + expect(wallet.encodeAddress(script)).toEqual({ + new: '', + legacy: '', + }); } }); @@ -233,14 +244,18 @@ describe('Wallet', () => { test('should blind Liquid addresses', () => { expect(walletLiquid.type).toEqual(CurrencyType.Liquid); - expect( - walletLiquid.encodeAddress(encodeOutput).startsWith('lq1qq'), - ).toBeTruthy(); + const enc = walletLiquid.encodeAddress(encodeOutput); + expect(enc.new.startsWith('lq1qq')).toBeTruthy(); + expect(enc.legacy.startsWith('lq1qq')).toBeTruthy(); + + expect(enc.new).not.toEqual(enc.legacy); }); test('should encode unblinded Liquid addresses', () => { - expect( - walletLiquid.encodeAddress(encodeOutput, false).startsWith('ex'), - ).toBeTruthy(); + const enc = walletLiquid.encodeAddress(encodeOutput, false); + expect(enc.new.startsWith('ex')).toBeTruthy(); + expect(enc.legacy.startsWith('ex')).toBeTruthy(); + + expect(enc.new).toEqual(enc.legacy); }); }); diff --git a/test/unit/wallet/WalletLiquid.spec.ts b/test/unit/wallet/WalletLiquid.spec.ts index 7441a599..b8b1bca8 100644 --- a/test/unit/wallet/WalletLiquid.spec.ts +++ b/test/unit/wallet/WalletLiquid.spec.ts @@ -1,4 +1,5 @@ import ops from '@boltz/bitcoin-ops'; +import { mnemonicToSeedSync } from 'bip39'; import { crypto } from 'bitcoinjs-lib'; import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; import { Scripts } from 'boltz-core'; @@ -12,9 +13,10 @@ import WalletLiquid from '../../../lib/wallet/WalletLiquid'; import WalletProviderInterface from '../../../lib/wallet/providers/WalletProviderInterface'; describe('WalletLiquid', () => { - const slip77 = SLIP77Factory(ecc).fromSeed( - 'test test test test test test test test test test test junk', - ); + const slip77 = SLIP77Factory(ecc); + const mnemonic = + 'test test test test test test test test test test test junk'; + const provider = { serviceName: () => 'Elements', } as WalletProviderInterface; @@ -22,7 +24,10 @@ describe('WalletLiquid', () => { const wallet = new WalletLiquid( Logger.disabledLogger, provider, - slip77, + { + new: slip77.fromSeed(mnemonicToSeedSync(mnemonic)), + legacy: slip77.fromSeed(mnemonic), + }, Networks.liquidRegtest, ); wallet.initKeyProvider('', 0, {} as any); @@ -38,8 +43,12 @@ describe('WalletLiquid', () => { ${'mainnet'} | ${Networks.liquidMainnet} | ${false} `('should check if $name supports discount CT', ({ network, support }) => { expect( - new WalletLiquid(Logger.disabledLogger, provider, slip77, network) - .supportsDiscountCT, + new WalletLiquid( + Logger.disabledLogger, + provider, + wallet['slip77s'], + network, + ).supportsDiscountCT, ).toEqual(support); }); @@ -52,7 +61,10 @@ describe('WalletLiquid', () => { const blindingKey = wallet.deriveBlindingKeyFromScript( address.toOutputScript(addr, Networks.liquidRegtest), ); - expect(blindingKey.privateKey).toMatchSnapshot(); + expect({ + new: blindingKey.new.privateKey, + legacy: blindingKey.new.privateKey, + }).toMatchSnapshot(); }); test.each` @@ -75,21 +87,23 @@ describe('WalletLiquid', () => { ); test('should return empty string as address for an empty script', () => { - expect(wallet.encodeAddress(Buffer.alloc(0), true)).toEqual(''); - expect(wallet.encodeAddress(Buffer.alloc(0), false)).toEqual(''); + expect(wallet.encodeAddress(Buffer.alloc(0), true).new).toEqual(''); + expect(wallet.encodeAddress(Buffer.alloc(0), true).legacy).toEqual(''); + expect(wallet.encodeAddress(Buffer.alloc(0), false).new).toEqual(''); + expect(wallet.encodeAddress(Buffer.alloc(0), false).legacy).toEqual(''); }); test('should return empty string as address for scripts that cannot be encoded', () => { const outputScript = Buffer.from([ops.OP_RETURN, 1, 2, 3]); - expect(wallet.encodeAddress(outputScript, true)).toEqual(''); - expect(wallet.encodeAddress(outputScript, false)).toEqual(''); + expect(wallet.encodeAddress(outputScript, true).new).toEqual(''); + expect(wallet.encodeAddress(outputScript, true).legacy).toEqual(''); + expect(wallet.encodeAddress(outputScript, false).new).toEqual(''); + expect(wallet.encodeAddress(outputScript, false).legacy).toEqual(''); }); test('should blind by default', () => { - expect( - wallet - .encodeAddress(Scripts.p2trOutput(toXOnly(publicKey))) - .startsWith(Networks.liquidRegtest.blech32), - ).toEqual(true); + const res = wallet.encodeAddress(Scripts.p2trOutput(toXOnly(publicKey))); + expect(res.new.startsWith(Networks.liquidRegtest.blech32)).toEqual(true); + expect(res.legacy.startsWith(Networks.liquidRegtest.blech32)).toEqual(true); }); }); diff --git a/test/unit/wallet/__snapshots__/WalletLiquid.spec.ts.snap b/test/unit/wallet/__snapshots__/WalletLiquid.spec.ts.snap index f05242dc..12f8c415 100644 --- a/test/unit/wallet/__snapshots__/WalletLiquid.spec.ts.snap +++ b/test/unit/wallet/__snapshots__/WalletLiquid.spec.ts.snap @@ -2,140 +2,307 @@ exports[`WalletLiquid should derive blinding keys from script for address AzpvTi6t8GTVxhg6tZ4vxNCdQKS9YYMMgJmxQAFYfhVvaogb6iVgTixvtv246tSbeM3zdgG1Z2ToreMt 1`] = ` { - "data": [ - 106, - 224, - 188, - 246, - 172, - 239, - 156, - 229, - 189, - 135, - 73, - 11, - 203, - 152, - 88, - 222, - 36, - 241, - 201, - 64, - 47, - 31, - 102, - 79, - 140, - 247, - 159, - 225, - 105, - 38, - 82, - 74, - ], - "type": "Buffer", + "legacy": { + "data": [ + 29, + 186, + 236, + 127, + 196, + 39, + 209, + 91, + 243, + 244, + 186, + 10, + 168, + 220, + 228, + 192, + 229, + 167, + 48, + 114, + 37, + 168, + 224, + 59, + 183, + 247, + 186, + 49, + 38, + 215, + 131, + 96, + ], + "type": "Buffer", + }, + "new": { + "data": [ + 29, + 186, + 236, + 127, + 196, + 39, + 209, + 91, + 243, + 244, + 186, + 10, + 168, + 220, + 228, + 192, + 229, + 167, + 48, + 114, + 37, + 168, + 224, + 59, + 183, + 247, + 186, + 49, + 38, + 215, + 131, + 96, + ], + "type": "Buffer", + }, } `; exports[`WalletLiquid should derive blinding keys from script for address CTEvqk9mbKSWnkPhF7DwqHf5X5Jx1Q25LXh4sprdqj4KRgMZTZaiGrhCCDfWrDyVqBbxUrhyCtLwgB7J 1`] = ` { - "data": [ - 9, - 92, - 208, - 181, - 55, - 69, - 139, - 221, - 246, - 80, - 157, - 160, - 170, - 232, - 140, - 154, - 1, - 116, - 134, - 90, - 46, - 200, - 30, - 67, - 0, - 44, - 174, - 107, - 164, - 253, - 26, - 160, - ], - "type": "Buffer", + "legacy": { + "data": [ + 76, + 102, + 175, + 87, + 146, + 230, + 75, + 33, + 136, + 123, + 191, + 76, + 90, + 254, + 173, + 205, + 88, + 159, + 82, + 220, + 249, + 103, + 108, + 7, + 219, + 120, + 133, + 184, + 23, + 47, + 48, + 59, + ], + "type": "Buffer", + }, + "new": { + "data": [ + 76, + 102, + 175, + 87, + 146, + 230, + 75, + 33, + 136, + 123, + 191, + 76, + 90, + 254, + 173, + 205, + 88, + 159, + 82, + 220, + 249, + 103, + 108, + 7, + 219, + 120, + 133, + 184, + 23, + 47, + 48, + 59, + ], + "type": "Buffer", + }, } `; exports[`WalletLiquid should derive blinding keys from script for address el1qqwxdjljvzdukcfxeammq6jktrvcurh329k7j208pm4rwe7anu2luhh7255kde06j2et2d0g5ym6yy949rx6rkp04xeav62vjp 1`] = ` { - "data": [ - 171, - 75, - 129, - 24, - 207, - 84, - 235, - 215, - 147, - 250, - 37, - 130, - 147, - 233, - 213, - 160, - 19, - 41, - 33, - 225, - 54, - 79, - 71, - 225, - 253, - 123, - 142, - 144, - 95, - 102, - 250, - 29, - ], - "type": "Buffer", + "legacy": { + "data": [ + 15, + 226, + 144, + 9, + 189, + 45, + 141, + 155, + 16, + 252, + 77, + 160, + 140, + 56, + 254, + 130, + 223, + 223, + 209, + 15, + 178, + 48, + 78, + 243, + 96, + 11, + 218, + 176, + 210, + 117, + 27, + 32, + ], + "type": "Buffer", + }, + "new": { + "data": [ + 15, + 226, + 144, + 9, + 189, + 45, + 141, + 155, + 16, + 252, + 77, + 160, + 140, + 56, + 254, + 130, + 223, + 223, + 209, + 15, + 178, + 48, + 78, + 243, + 96, + 11, + 218, + 176, + 210, + 117, + 27, + 32, + ], + "type": "Buffer", + }, } `; -exports[`WalletLiquid should encode P2PKH address (confidential: false) 1`] = `"2df2n73X6jTPddy31rjMfgZhxC6bkcEU9XT"`; +exports[`WalletLiquid should encode P2PKH address (confidential: false) 1`] = ` +{ + "legacy": "2df2n73X6jTPddy31rjMfgZhxC6bkcEU9XT", + "new": "2df2n73X6jTPddy31rjMfgZhxC6bkcEU9XT", +} +`; -exports[`WalletLiquid should encode P2PKH address (confidential: true) 1`] = `"CTEnqWfNvTGTtoYYQL6WiAvNcHDWBWa1rSYBfph2zsUGHBafMs2P8wetoiQSwzvyzQjZK12EzBgZpkLW"`; +exports[`WalletLiquid should encode P2PKH address (confidential: true) 1`] = ` +{ + "legacy": "CTEnqWfNvTGTtoYYQL6WiAvNcHDWBWa1rSYBfph2zsUGHBafMs2P8wetoiQSwzvyzQjZK12EzBgZpkLW", + "new": "CTEmC4Fuy5mW1WadkUtGrNRMbzVjdhvV7XpsmQdQpoouzgeb4Cj1bTAyiyLSNayRtWhvn8aowyGDYsGQ", +} +`; -exports[`WalletLiquid should encode P2SH address (confidential: false) 1`] = `"XGxGZfq18bFSncj9wc4g6WA5VPNprJdAmg"`; +exports[`WalletLiquid should encode P2SH address (confidential: false) 1`] = ` +{ + "legacy": "XGxGZfq18bFSncj9wc4g6WA5VPNprJdAmg", + "new": "XGxGZfq18bFSncj9wc4g6WA5VPNprJdAmg", +} +`; -exports[`WalletLiquid should encode P2SH address (confidential: true) 1`] = `"Azppnmg47wJVTyJvBWUR4vkgZQ6gkK2VkJJrwsUbiLWGmFMWZDScTd6NmrX3LND5yM2UE9SGesLs5KfX"`; +exports[`WalletLiquid should encode P2SH address (confidential: true) 1`] = ` +{ + "legacy": "Azppnmg47wJVTyJvBWUR4vkgZQ6gkK2VkJJrwsUbiLWGmFMWZDScTd6NmrX3LND5yM2UE9SGesLs5KfX", + "new": "Azpnzu3T6kDTbKLcQW7GnTqB5gK4jRCzsuVVjhFZbxr8MBAcjrRRFgK2ZAq8N2Nqu5zr8g1cQFHmTsTD", +} +`; -exports[`WalletLiquid should encode P2TR address (confidential: false) 1`] = `"ert1p7qypc2gpr437ws0yhl3yvk57rweq8pfdyw04g8vjmjxeus9ak0nqwf79lm"`; +exports[`WalletLiquid should encode P2TR address (confidential: false) 1`] = ` +{ + "legacy": "ert1p7qypc2gpr437ws0yhl3yvk57rweq8pfdyw04g8vjmjxeus9ak0nqwf79lm", + "new": "ert1p7qypc2gpr437ws0yhl3yvk57rweq8pfdyw04g8vjmjxeus9ak0nqwf79lm", +} +`; -exports[`WalletLiquid should encode P2TR address (confidential: true) 1`] = `"el1pq2w6dcmt9smmce3ear69m9yuk328gm6hx05vyk93uutv0ugzx7mp0uqgrs5sz8truaq7f0lzgedfuxajqwzj6gul2swe9hydneqtmvlxvadnu96fepd2"`; +exports[`WalletLiquid should encode P2TR address (confidential: true) 1`] = ` +{ + "legacy": "el1pq2w6dcmt9smmce3ear69m9yuk328gm6hx05vyk93uutv0ugzx7mp0uqgrs5sz8truaq7f0lzgedfuxajqwzj6gul2swe9hydneqtmvlxvadnu96fepd2", + "new": "el1pq2zv23w6zq3hhe9tp6e4huzs353pjzg7he4vz72c7t8ul7en9mun9uqgrs5sz8truaq7f0lzgedfuxajqwzj6gul2swe9hydneqtmvlx6qsuxc6rp3e5", +} +`; -exports[`WalletLiquid should encode P2WPKH address (confidential: false) 1`] = `"ert1q84ufadu3juwu6zjcdcweqhe9ewhwxrvtj3lmzj"`; +exports[`WalletLiquid should encode P2WPKH address (confidential: false) 1`] = ` +{ + "legacy": "ert1q84ufadu3juwu6zjcdcweqhe9ewhwxrvtj3lmzj", + "new": "ert1q84ufadu3juwu6zjcdcweqhe9ewhwxrvtj3lmzj", +} +`; -exports[`WalletLiquid should encode P2WPKH address (confidential: true) 1`] = `"el1qqdx8446sv2hzmnphuzkt2ct848n05zt6r6ue72nl56l6xjf2p0exk0tcn6mer9cae599smsajp0jtjawuvxcklqsf6r8tdrqz"`; +exports[`WalletLiquid should encode P2WPKH address (confidential: true) 1`] = ` +{ + "legacy": "el1qqdx8446sv2hzmnphuzkt2ct848n05zt6r6ue72nl56l6xjf2p0exk0tcn6mer9cae599smsajp0jtjawuvxcklqsf6r8tdrqz", + "new": "el1qqgvxf2pj4v384v5ju9t8ru6avh3c3xqwez8nlnvuq8jvyataykygy0tcn6mer9cae599smsajp0jtjawuvxckk33f057ud4s8", +} +`; -exports[`WalletLiquid should encode P2WSH address (confidential: false) 1`] = `"ert1qxxywq89mszpuv6t4puyfypf8qafcdg4zcwed69ku4sr6tlg0tteq7kr8c6"`; +exports[`WalletLiquid should encode P2WSH address (confidential: false) 1`] = ` +{ + "legacy": "ert1qxxywq89mszpuv6t4puyfypf8qafcdg4zcwed69ku4sr6tlg0tteq7kr8c6", + "new": "ert1qxxywq89mszpuv6t4puyfypf8qafcdg4zcwed69ku4sr6tlg0tteq7kr8c6", +} +`; -exports[`WalletLiquid should encode P2WSH address (confidential: true) 1`] = `"el1qqfq4vetpg6ly3dapjcltgyg0475nqcq4592tcvuc25t2t0y84s692vvguqwthqyrce5h2rcgjgzjwp6ns6329sajm5tdetq85h7s7khj388xz2mq4qgg"`; +exports[`WalletLiquid should encode P2WSH address (confidential: true) 1`] = ` +{ + "legacy": "el1qqfq4vetpg6ly3dapjcltgyg0475nqcq4592tcvuc25t2t0y84s692vvguqwthqyrce5h2rcgjgzjwp6ns6329sajm5tdetq85h7s7khj388xz2mq4qgg", + "new": "el1qqt3q2gtj0zxadv57n5e3q3rry4w8nsek92clvx30zxcwuf828n2qxvvguqwthqyrce5h2rcgjgzjwp6ns6329sajm5tdetq85h7s7khj7vkrq76xq9tg", +} +`;