Skip to content
Closed
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
1 change: 1 addition & 0 deletions rust/main/Cargo.lock

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

34 changes: 33 additions & 1 deletion rust/main/agents/relayer/src/relayer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,26 @@ impl BaseAgent for Relayer {
.map(|(key, origin)| (key.id(), origin.prover_sync.clone()))
.collect();

// Build kaspa recovery config if available
let kaspa_recovery = if let Some(dym_args) = self.dymension_kaspa_args.as_ref() {
let sender_guard = dym_args.recovery_sender.read().unwrap();
sender_guard
.as_ref()
.map(|sender| relayer_server::KaspaRecoveryConfig {
sender: sender.clone(),
rest_api_url: dym_args
.kas_provider
.conf()
.kaspa_urls_rest
.first()
.map(|u| u.to_string())
.unwrap_or_default(),
escrow_address: dym_args.kas_provider.escrow_address().to_string(),
})
} else {
None
};

let relayer_router = relayer_server::Server::new(self.destinations.len())
.with_op_retry(sender.clone())
.with_message_queue(prep_queues)
Expand All @@ -596,7 +616,8 @@ impl BaseAgent for Relayer {
self.dymension_kaspa_args
.as_ref()
.and_then(|dym_args| dym_args.kas_provider.kaspa_db().cloned()),
) // Set kaspa_db to server_builder from dymension_args provider if available
)
.with_kaspa_recovery(kaspa_recovery)
.router();

let server = self
Expand Down Expand Up @@ -1126,6 +1147,9 @@ impl Relayer {
struct DymensionKaspaArgs {
kas_provider: Box<KaspaProvider>,
dym_mailbox: Arc<CosmosNativeMailbox>,
/// Sender for deposit recovery requests, populated when Foo is created
recovery_sender:
Arc<std::sync::RwLock<Option<hyperlane_base::kas_hack::DepositRecoverySender>>>,
}

// Manual Debug since KaspaMailbox now has a trait object
Expand All @@ -1135,6 +1159,7 @@ impl std::fmt::Debug for DymensionKaspaArgs {
.field("kas_provider", &self.kas_provider)
.field("kas_mailbox", &"KaspaMailbox")
.field("dym_mailbox", &self.dym_mailbox)
.field("recovery_sender", &"<RwLock>")
.finish()
}
}
Expand Down Expand Up @@ -1197,6 +1222,7 @@ impl Relayer {
Ok(Some(DymensionKaspaArgs {
kas_provider,
dym_mailbox,
recovery_sender: Arc::new(std::sync::RwLock::new(None)),
}))
}

Expand All @@ -1218,6 +1244,12 @@ impl Relayer {

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

// Store the recovery sender for use by the server endpoint
{
let mut sender_guard = args.recovery_sender.write().unwrap();
*sender_guard = Some(b.recovery_sender());
}

// sync relayer before starting other tasks
b.sync_hub_if_needed().await.unwrap();

Expand Down
54 changes: 51 additions & 3 deletions rust/main/agents/relayer/src/server/kaspa/mod.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,66 @@
use std::sync::Arc;

use axum::{routing::get, Router};
use derive_new::new;
use axum::{
routing::{get, post},
Router,
};
use dymension_kaspa::dym_kas_core::api::{base::RateLimitConfig, client::HttpClient};
use hyperlane_base::kas_hack::DepositRecoverySender;
use hyperlane_core::KaspaDb;
use tower_http::cors::{Any, CorsLayer};

pub mod list_deposits;
pub mod list_withdrawals;
pub mod recover_deposit;

#[derive(Clone, Debug, new)]
/// Configuration for deposit recovery functionality
#[derive(Clone)]
pub struct RecoveryConfig {
pub sender: DepositRecoverySender,
pub http_client: HttpClient,
pub escrow_address: String,
}

/// Server state for Kaspa endpoints
#[derive(Clone)]
pub struct ServerState {
pub kaspa_db: Arc<dyn KaspaDb>,
/// Optional recovery configuration (sender, HTTP client, escrow address)
pub recovery: Option<RecoveryConfig>,
}

impl std::fmt::Debug for ServerState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerState")
.field("kaspa_db", &"<dyn KaspaDb>")
.field("recovery", &self.recovery.is_some())
.finish()
}
}

impl ServerState {
pub fn new(kaspa_db: Arc<dyn KaspaDb>) -> Self {
Self {
kaspa_db,
recovery: None,
}
}

pub fn with_recovery(
mut self,
sender: DepositRecoverySender,
rest_api_url: String,
escrow_address: String,
) -> Self {
let http_client = HttpClient::new(rest_api_url, RateLimitConfig::default());
self.recovery = Some(RecoveryConfig {
sender,
http_client,
escrow_address,
});
self
}

pub fn router(self) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
Expand All @@ -23,6 +70,7 @@ impl ServerState {
Router::new()
.route("/kaspa/deposit", get(list_deposits::handler))
.route("/kaspa/withdrawal", get(list_withdrawals::handler))
.route("/kaspa/deposit/recover", post(recover_deposit::handler))
.layer(cors)
.with_state(self)
}
Expand Down
117 changes: 117 additions & 0 deletions rust/main/agents/relayer/src/server/kaspa/recover_deposit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use axum::{extract::State, http::StatusCode, Json};
use dymension_kaspa::dym_kas_core::api::client::Deposit;
use serde::{Deserialize, Serialize};

use hyperlane_base::server::utils::{
ServerErrorBody, ServerErrorResponse, ServerResult, ServerSuccessResponse,
};

use super::ServerState;

#[derive(Clone, Debug, Deserialize)]
pub struct RequestBody {
/// The Kaspa transaction ID to recover
pub kaspa_tx: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct ResponseBody {
pub message: String,
pub deposit_id: String,
}

/// Recover a Kaspa deposit by fetching it from the Kaspa REST API and submitting
/// it for processing. This is useful for deposits that fell outside the normal
/// lookback window due to relayer DB being wiped or other issues.
///
/// POST /kaspa/deposit/recover
/// Body: { "kaspa_tx": "242b5987..." }
pub async fn handler(
State(state): State<ServerState>,
Json(body): Json<RequestBody>,
) -> ServerResult<ServerSuccessResponse<ResponseBody>> {
let RequestBody { kaspa_tx } = body;
tracing::info!(%kaspa_tx, "Received deposit recovery request");

// Check if recovery is enabled
let recovery = state.recovery.as_ref().ok_or_else(|| {
ServerErrorResponse::new(
StatusCode::SERVICE_UNAVAILABLE,
ServerErrorBody {
message: "Deposit recovery is not enabled on this relayer".to_string(),
},
)
})?;

// Fetch the transaction from Kaspa REST API
let tx = recovery
.http_client
.get_tx_by_id(&kaspa_tx)
.await
.map_err(|e| {
tracing::error!(%kaspa_tx, error = ?e, "Failed to fetch transaction from Kaspa API");
ServerErrorResponse::new(
StatusCode::NOT_FOUND,
ServerErrorBody {
message: format!("Transaction not found or API error: {}", e),
},
)
})?;

// Validate it's a valid escrow transfer
if !is_valid_escrow_transfer(&tx, &recovery.escrow_address) {
return Err(ServerErrorResponse::new(
StatusCode::BAD_REQUEST,
ServerErrorBody {
message: format!(
"Transaction {} is not a valid deposit to escrow {}",
kaspa_tx, recovery.escrow_address
),
},
));
}

// Convert to Deposit
let deposit: Deposit = tx.try_into().map_err(|e: eyre::Error| {
tracing::error!(%kaspa_tx, error = ?e, "Failed to convert transaction to deposit");
ServerErrorResponse::new(
StatusCode::BAD_REQUEST,
ServerErrorBody {
message: format!("Invalid deposit transaction: {}", e),
},
)
})?;

let deposit_id = deposit.id.to_string();

// Send to recovery channel
recovery.sender.send(deposit).await.map_err(|e| {
tracing::error!(%kaspa_tx, error = ?e, "Failed to send deposit to recovery channel");
ServerErrorResponse::new(
StatusCode::INTERNAL_SERVER_ERROR,
ServerErrorBody {
message: "Failed to queue deposit for recovery".to_string(),
},
)
})?;

tracing::info!(%kaspa_tx, %deposit_id, "Deposit queued for recovery");

Ok(ServerSuccessResponse::new(ResponseBody {
message: "Deposit queued for recovery processing".to_string(),
deposit_id,
}))
}

fn is_valid_escrow_transfer(
tx: &dymension_kaspa::dym_kas_api::models::TxModel,
escrow_address: &str,
) -> bool {
tx.outputs.as_ref().map_or(false, |outputs| {
outputs.iter().any(|utxo| {
utxo.script_public_key_address
.as_ref()
.map_or(false, |dest| dest == escrow_address)
})
})
}
27 changes: 26 additions & 1 deletion rust/main/agents/relayer/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::sync::Arc;

use axum::Router;
use derive_new::new;
use hyperlane_base::kas_hack::DepositRecoverySender;
use hyperlane_core::HyperlaneDomain;
use tokio::sync::broadcast::Sender;

Expand All @@ -27,6 +28,13 @@ pub mod messages;
pub mod operations;
pub mod proofs;

/// Kaspa recovery configuration for the server
pub struct KaspaRecoveryConfig {
pub sender: DepositRecoverySender,
pub rest_api_url: String,
pub escrow_address: String,
}

#[derive(new)]
pub struct Server {
destination_chains: usize,
Expand All @@ -45,6 +53,8 @@ pub struct Server {
prover_syncs: Option<HashMap<u32, Arc<RwLock<MerkleTreeBuilder>>>>,
#[new(default)]
kaspa_db: Option<Arc<dyn KaspaDb>>,
#[new(default)]
kaspa_recovery: Option<KaspaRecoveryConfig>,
}

impl Server {
Expand Down Expand Up @@ -92,6 +102,11 @@ impl Server {
self
}

pub fn with_kaspa_recovery(mut self, recovery: Option<KaspaRecoveryConfig>) -> Self {
self.kaspa_recovery = recovery;
self
}

// return a custom router that can be used in combination with other routers
pub fn router(self) -> Router {
let mut router = Router::new();
Expand Down Expand Up @@ -127,7 +142,17 @@ impl Server {
router = router.merge(proofs::ServerState::new(prover_syncs).router());
}
if let Some(kaspa_db) = self.kaspa_db {
router = router.merge(kaspa::ServerState::new(kaspa_db).router());
let kaspa_state = kaspa::ServerState::new(kaspa_db);
let kaspa_state = if let Some(recovery) = self.kaspa_recovery {
kaspa_state.with_recovery(
recovery.sender,
recovery.rest_api_url,
recovery.escrow_address,
)
} else {
kaspa_state
};
router = router.merge(kaspa_state.router());
}

let expose_environment_variable_endpoint =
Expand Down
1 change: 1 addition & 0 deletions rust/main/chains/dymension-kaspa/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod validator;

// Direct reexports of lib stuff:
pub use consts as hl_domains;
pub use dym_kas_api;
pub use dym_kas_core;
pub use kaspa_addresses::Address as KaspaAddress;

Expand Down
4 changes: 4 additions & 0 deletions rust/main/chains/dymension-kaspa/src/providers/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ impl KaspaProvider {
.expect("Kaspa key source not configured")
}

pub fn try_kas_key_source(&self) -> Option<&crate::conf::KaspaEscrowKeySource> {
self.kas_key_source.as_ref()
}

pub fn rest(&self) -> &RestProvider {
&self.rest
}
Expand Down
Loading
Loading