Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: swap recovery rescan #820

Merged
merged 2 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions boltzr/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions boltzr/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
15 changes: 14 additions & 1 deletion boltzr/src/api/lightning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
12 changes: 4 additions & 8 deletions boltzr/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -18,6 +19,7 @@ use ws::types::SwapStatus;
mod errors;
mod headers;
mod lightning;
mod recovery;
mod sse;
mod stats;
mod types;
Expand Down Expand Up @@ -123,6 +125,7 @@ where
"/v2/swap/{swap_type}/stats/{from}/{to}",
get(get_stats::<S, M>),
)
.route("/v2/swap/recovery", post(swap_recovery::<S, M>))
.route(
"/v2/lightning/{currency}/node/{node}",
get(lightning::node_info::<S, M>),
Expand All @@ -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;
Expand Down Expand Up @@ -196,12 +197,7 @@ pub mod test {
},
cancel.clone(),
Arc::new(MockManager::new()),
Arc::new(Service::new::<Redis>(
Arc::new(HashMap::new()),
None,
None,
None,
)),
Arc::new(Service::new_mocked_prometheus(false)),
Fetcher {
status_tx: status_tx.clone(),
},
Expand Down
146 changes: 146 additions & 0 deletions boltzr/src/api/recovery.rs
Original file line number Diff line number Diff line change
@@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, value: &str) -> Result<Self::Value, E>
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<S, M>(
Extension(state): Extension<Arc<ServerState<S, M>>>,
Json(RecoveryParams { xpub }): Json<RecoveryParams>,
) -> anyhow::Result<impl IntoResponse, AxumError>
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::<Vec<SwapStatus>>(1);
Server::<Fetcher, MockManager>::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::<Vec<RecoverableSwap>>(&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::<ApiError>(&body).unwrap().error,
"Failed to deserialize the JSON body into the target type: xpub: invalid xpub: base58 encoding error at line 1 column 17"
);
}
}
25 changes: 16 additions & 9 deletions boltzr/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub struct GlobalConfig {
#[serde(rename = "profilingEndpoint")]
pub profiling_endpoint: Option<String>,

#[serde(rename = "mnemonicpath")]
pub mnemonic_path: Option<String>,

#[serde(rename = "mnemonicpathEvm")]
pub mnemonic_path_evm: Option<String>,

Expand All @@ -77,16 +80,20 @@ pub fn parse_config(path: &str) -> Result<GlobalConfig, Box<dyn Error>> {
let mut config = toml::from_str::<GlobalConfig>(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(
Expand Down
Loading
Loading