Skip to content

Commit dbb6b9a

Browse files
danwtclaude
andcommitted
claude: feat(relayer): add deposit recovery endpoint
Add POST /kaspa/deposit/recover endpoint to manually recover deposits that fell outside the normal lookback window due to relayer DB being wiped or other issues. The endpoint: - Accepts a kaspa_tx ID - Fetches the transaction from Kaspa REST API - Validates it's a valid escrow transfer - Queues it for processing via the existing deposit pipeline Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 15b268f commit dbb6b9a

7 files changed

Lines changed: 265 additions & 6 deletions

File tree

rust/main/agents/relayer/src/relayer.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,26 @@ impl BaseAgent for Relayer {
585585
.map(|(key, origin)| (key.id(), origin.prover_sync.clone()))
586586
.collect();
587587

588+
// Build kaspa recovery config if available
589+
let kaspa_recovery = if let Some(dym_args) = self.dymension_kaspa_args.as_ref() {
590+
let sender_guard = dym_args.recovery_sender.read().unwrap();
591+
sender_guard
592+
.as_ref()
593+
.map(|sender| relayer_server::KaspaRecoveryConfig {
594+
sender: sender.clone(),
595+
rest_api_url: dym_args
596+
.kas_provider
597+
.conf()
598+
.kaspa_urls_rest
599+
.first()
600+
.map(|u| u.to_string())
601+
.unwrap_or_default(),
602+
escrow_address: dym_args.kas_provider.escrow_address().to_string(),
603+
})
604+
} else {
605+
None
606+
};
607+
588608
let relayer_router = relayer_server::Server::new(self.destinations.len())
589609
.with_op_retry(sender.clone())
590610
.with_message_queue(prep_queues)
@@ -596,7 +616,8 @@ impl BaseAgent for Relayer {
596616
self.dymension_kaspa_args
597617
.as_ref()
598618
.and_then(|dym_args| dym_args.kas_provider.kaspa_db().cloned()),
599-
) // Set kaspa_db to server_builder from dymension_args provider if available
619+
)
620+
.with_kaspa_recovery(kaspa_recovery)
600621
.router();
601622

602623
let server = self
@@ -1126,6 +1147,9 @@ impl Relayer {
11261147
struct DymensionKaspaArgs {
11271148
kas_provider: Box<KaspaProvider>,
11281149
dym_mailbox: Arc<CosmosNativeMailbox>,
1150+
/// Sender for deposit recovery requests, populated when Foo is created
1151+
recovery_sender:
1152+
Arc<std::sync::RwLock<Option<hyperlane_base::kas_hack::DepositRecoverySender>>>,
11291153
}
11301154

11311155
// Manual Debug since KaspaMailbox now has a trait object
@@ -1135,6 +1159,7 @@ impl std::fmt::Debug for DymensionKaspaArgs {
11351159
.field("kas_provider", &self.kas_provider)
11361160
.field("kas_mailbox", &"KaspaMailbox")
11371161
.field("dym_mailbox", &self.dym_mailbox)
1162+
.field("recovery_sender", &"<RwLock>")
11381163
.finish()
11391164
}
11401165
}
@@ -1197,6 +1222,7 @@ impl Relayer {
11971222
Ok(Some(DymensionKaspaArgs {
11981223
kas_provider,
11991224
dym_mailbox,
1225+
recovery_sender: Arc::new(std::sync::RwLock::new(None)),
12001226
}))
12011227
}
12021228

@@ -1218,6 +1244,12 @@ impl Relayer {
12181244

12191245
let b = KaspaBridgeFoo::new(kas_provider.clone(), hub_mailbox.clone(), metadata_getter);
12201246

1247+
// Store the recovery sender for use by the server endpoint
1248+
{
1249+
let mut sender_guard = args.recovery_sender.write().unwrap();
1250+
*sender_guard = Some(b.recovery_sender());
1251+
}
1252+
12211253
// sync relayer before starting other tasks
12221254
b.sync_hub_if_needed().await.unwrap();
12231255

rust/main/agents/relayer/src/server/kaspa/mod.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,66 @@
11
use std::sync::Arc;
22

3-
use axum::{routing::get, Router};
4-
use derive_new::new;
3+
use axum::{
4+
routing::{get, post},
5+
Router,
6+
};
7+
use dymension_kaspa::dym_kas_core::api::{base::RateLimitConfig, client::HttpClient};
8+
use hyperlane_base::kas_hack::DepositRecoverySender;
59
use hyperlane_core::KaspaDb;
610
use tower_http::cors::{Any, CorsLayer};
711

812
pub mod list_deposits;
913
pub mod list_withdrawals;
14+
pub mod recover_deposit;
1015

11-
#[derive(Clone, Debug, new)]
16+
/// Configuration for deposit recovery functionality
17+
#[derive(Clone)]
18+
pub struct RecoveryConfig {
19+
pub sender: DepositRecoverySender,
20+
pub http_client: HttpClient,
21+
pub escrow_address: String,
22+
}
23+
24+
/// Server state for Kaspa endpoints
25+
#[derive(Clone)]
1226
pub struct ServerState {
1327
pub kaspa_db: Arc<dyn KaspaDb>,
28+
/// Optional recovery configuration (sender, HTTP client, escrow address)
29+
pub recovery: Option<RecoveryConfig>,
30+
}
31+
32+
impl std::fmt::Debug for ServerState {
33+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34+
f.debug_struct("ServerState")
35+
.field("kaspa_db", &"<dyn KaspaDb>")
36+
.field("recovery", &self.recovery.is_some())
37+
.finish()
38+
}
1439
}
1540

1641
impl ServerState {
42+
pub fn new(kaspa_db: Arc<dyn KaspaDb>) -> Self {
43+
Self {
44+
kaspa_db,
45+
recovery: None,
46+
}
47+
}
48+
49+
pub fn with_recovery(
50+
mut self,
51+
sender: DepositRecoverySender,
52+
rest_api_url: String,
53+
escrow_address: String,
54+
) -> Self {
55+
let http_client = HttpClient::new(rest_api_url, RateLimitConfig::default());
56+
self.recovery = Some(RecoveryConfig {
57+
sender,
58+
http_client,
59+
escrow_address,
60+
});
61+
self
62+
}
63+
1764
pub fn router(self) -> Router {
1865
let cors = CorsLayer::new()
1966
.allow_origin(Any)
@@ -23,6 +70,7 @@ impl ServerState {
2370
Router::new()
2471
.route("/kaspa/deposit", get(list_deposits::handler))
2572
.route("/kaspa/withdrawal", get(list_withdrawals::handler))
73+
.route("/kaspa/deposit/recover", post(recover_deposit::handler))
2674
.layer(cors)
2775
.with_state(self)
2876
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use axum::{extract::State, http::StatusCode, Json};
2+
use dymension_kaspa::dym_kas_core::api::client::Deposit;
3+
use serde::{Deserialize, Serialize};
4+
5+
use hyperlane_base::server::utils::{
6+
ServerErrorBody, ServerErrorResponse, ServerResult, ServerSuccessResponse,
7+
};
8+
9+
use super::ServerState;
10+
11+
#[derive(Clone, Debug, Deserialize)]
12+
pub struct RequestBody {
13+
/// The Kaspa transaction ID to recover
14+
pub kaspa_tx: String,
15+
}
16+
17+
#[derive(Clone, Debug, Serialize)]
18+
pub struct ResponseBody {
19+
pub message: String,
20+
pub deposit_id: String,
21+
}
22+
23+
/// Recover a Kaspa deposit by fetching it from the Kaspa REST API and submitting
24+
/// it for processing. This is useful for deposits that fell outside the normal
25+
/// lookback window due to relayer DB being wiped or other issues.
26+
///
27+
/// POST /kaspa/deposit/recover
28+
/// Body: { "kaspa_tx": "242b5987..." }
29+
pub async fn handler(
30+
State(state): State<ServerState>,
31+
Json(body): Json<RequestBody>,
32+
) -> ServerResult<ServerSuccessResponse<ResponseBody>> {
33+
let RequestBody { kaspa_tx } = body;
34+
tracing::info!(%kaspa_tx, "Received deposit recovery request");
35+
36+
// Check if recovery is enabled
37+
let recovery = state.recovery.as_ref().ok_or_else(|| {
38+
ServerErrorResponse::new(
39+
StatusCode::SERVICE_UNAVAILABLE,
40+
ServerErrorBody {
41+
message: "Deposit recovery is not enabled on this relayer".to_string(),
42+
},
43+
)
44+
})?;
45+
46+
// Fetch the transaction from Kaspa REST API
47+
let tx = recovery
48+
.http_client
49+
.get_tx_by_id(&kaspa_tx)
50+
.await
51+
.map_err(|e| {
52+
tracing::error!(%kaspa_tx, error = ?e, "Failed to fetch transaction from Kaspa API");
53+
ServerErrorResponse::new(
54+
StatusCode::NOT_FOUND,
55+
ServerErrorBody {
56+
message: format!("Transaction not found or API error: {}", e),
57+
},
58+
)
59+
})?;
60+
61+
// Validate it's a valid escrow transfer
62+
if !is_valid_escrow_transfer(&tx, &recovery.escrow_address) {
63+
return Err(ServerErrorResponse::new(
64+
StatusCode::BAD_REQUEST,
65+
ServerErrorBody {
66+
message: format!(
67+
"Transaction {} is not a valid deposit to escrow {}",
68+
kaspa_tx, recovery.escrow_address
69+
),
70+
},
71+
));
72+
}
73+
74+
// Convert to Deposit
75+
let deposit: Deposit = tx.try_into().map_err(|e: eyre::Error| {
76+
tracing::error!(%kaspa_tx, error = ?e, "Failed to convert transaction to deposit");
77+
ServerErrorResponse::new(
78+
StatusCode::BAD_REQUEST,
79+
ServerErrorBody {
80+
message: format!("Invalid deposit transaction: {}", e),
81+
},
82+
)
83+
})?;
84+
85+
let deposit_id = deposit.id.to_string();
86+
87+
// Send to recovery channel
88+
recovery.sender.send(deposit).await.map_err(|e| {
89+
tracing::error!(%kaspa_tx, error = ?e, "Failed to send deposit to recovery channel");
90+
ServerErrorResponse::new(
91+
StatusCode::INTERNAL_SERVER_ERROR,
92+
ServerErrorBody {
93+
message: "Failed to queue deposit for recovery".to_string(),
94+
},
95+
)
96+
})?;
97+
98+
tracing::info!(%kaspa_tx, %deposit_id, "Deposit queued for recovery");
99+
100+
Ok(ServerSuccessResponse::new(ResponseBody {
101+
message: "Deposit queued for recovery processing".to_string(),
102+
deposit_id,
103+
}))
104+
}
105+
106+
fn is_valid_escrow_transfer(
107+
tx: &dymension_kaspa::dym_kas_api::models::TxModel,
108+
escrow_address: &str,
109+
) -> bool {
110+
tx.outputs.as_ref().map_or(false, |outputs| {
111+
outputs.iter().any(|utxo| {
112+
utxo.script_public_key_address
113+
.as_ref()
114+
.map_or(false, |dest| dest == escrow_address)
115+
})
116+
})
117+
}

rust/main/agents/relayer/src/server/mod.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::sync::Arc;
44

55
use axum::Router;
66
use derive_new::new;
7+
use hyperlane_base::kas_hack::DepositRecoverySender;
78
use hyperlane_core::HyperlaneDomain;
89
use tokio::sync::broadcast::Sender;
910

@@ -27,6 +28,13 @@ pub mod messages;
2728
pub mod operations;
2829
pub mod proofs;
2930

31+
/// Kaspa recovery configuration for the server
32+
pub struct KaspaRecoveryConfig {
33+
pub sender: DepositRecoverySender,
34+
pub rest_api_url: String,
35+
pub escrow_address: String,
36+
}
37+
3038
#[derive(new)]
3139
pub struct Server {
3240
destination_chains: usize,
@@ -45,6 +53,8 @@ pub struct Server {
4553
prover_syncs: Option<HashMap<u32, Arc<RwLock<MerkleTreeBuilder>>>>,
4654
#[new(default)]
4755
kaspa_db: Option<Arc<dyn KaspaDb>>,
56+
#[new(default)]
57+
kaspa_recovery: Option<KaspaRecoveryConfig>,
4858
}
4959

5060
impl Server {
@@ -92,6 +102,11 @@ impl Server {
92102
self
93103
}
94104

105+
pub fn with_kaspa_recovery(mut self, recovery: Option<KaspaRecoveryConfig>) -> Self {
106+
self.kaspa_recovery = recovery;
107+
self
108+
}
109+
95110
// return a custom router that can be used in combination with other routers
96111
pub fn router(self) -> Router {
97112
let mut router = Router::new();
@@ -127,7 +142,17 @@ impl Server {
127142
router = router.merge(proofs::ServerState::new(prover_syncs).router());
128143
}
129144
if let Some(kaspa_db) = self.kaspa_db {
130-
router = router.merge(kaspa::ServerState::new(kaspa_db).router());
145+
let kaspa_state = kaspa::ServerState::new(kaspa_db);
146+
let kaspa_state = if let Some(recovery) = self.kaspa_recovery {
147+
kaspa_state.with_recovery(
148+
recovery.sender,
149+
recovery.rest_api_url,
150+
recovery.escrow_address,
151+
)
152+
} else {
153+
kaspa_state
154+
};
155+
router = router.merge(kaspa_state.router());
131156
}
132157

133158
let expose_environment_variable_endpoint =

rust/main/chains/dymension-kaspa/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub mod validator;
2626

2727
// Direct reexports of lib stuff:
2828
pub use consts as hl_domains;
29+
pub use dym_kas_api;
2930
pub use dym_kas_core;
3031
pub use kaspa_addresses::Address as KaspaAddress;
3132

0 commit comments

Comments
 (0)