Skip to content

Commit f18b74b

Browse files
committed
feat: authenticated WebSocket + validator whitelist endpoint
- WebSocket now requires signature verification for hotkey auth - Message format: 'ws_connect:{hotkey}:{timestamp}' - Timestamp must be within 5 minutes - Validators registered in DB on connect (updates last_seen) - New endpoint GET /api/v1/validators/whitelist - Returns validators with stake >= 10k TAO connected in last 24h
1 parent f3d52d0 commit f18b74b

File tree

5 files changed

+111
-4
lines changed

5 files changed

+111
-4
lines changed

crates/challenge-sdk/src/server.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ use serde::{Deserialize, Serialize};
3131
use tokio::sync::RwLock;
3232
use tracing::{debug, error, info, warn};
3333

34-
use crate::challenge::Challenge;
35-
use crate::context::ChallengeContext;
3634
use crate::error::ChallengeError;
3735

36+
#[cfg(feature = "http-server")]
37+
use axum::extract::State;
38+
3839
/// Server configuration
3940
#[derive(Debug, Clone)]
4041
pub struct ServerConfig {

crates/platform-server/src/api/validators.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,17 @@ pub async fn heartbeat(
7171
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
7272
Ok(Json(serde_json::json!({ "success": true })))
7373
}
74+
75+
/// Minimum stake required for whitelist (10,000 TAO in RAO)
76+
const MIN_STAKE_FOR_WHITELIST: i64 = 10_000_000_000_000;
77+
78+
/// GET /api/v1/validators/whitelist - Get whitelisted validators
79+
/// Returns validators with stake >= 10,000 TAO who connected in the last 24h
80+
pub async fn get_whitelisted_validators(
81+
State(state): State<Arc<AppState>>,
82+
) -> Result<Json<Vec<String>>, StatusCode> {
83+
let validators = queries::get_whitelisted_validators(&state.db, MIN_STAKE_FOR_WHITELIST)
84+
.await
85+
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
86+
Ok(Json(validators))
87+
}

crates/platform-server/src/db/queries.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ pub async fn get_total_stake(pool: &Pool) -> Result<u64> {
8989
Ok(stake as u64)
9090
}
9191

92+
/// Get whitelisted validators (stake >= min_stake and last_seen within 24h)
93+
pub async fn get_whitelisted_validators(pool: &Pool, min_stake: i64) -> Result<Vec<String>> {
94+
let client = pool.get().await?;
95+
let rows = client
96+
.query(
97+
"SELECT hotkey FROM validators
98+
WHERE is_active = TRUE
99+
AND stake >= $1
100+
AND last_seen >= NOW() - INTERVAL '24 hours'
101+
ORDER BY stake DESC",
102+
&[&min_stake],
103+
)
104+
.await?;
105+
106+
Ok(rows.iter().map(|row| row.get(0)).collect())
107+
}
108+
92109
// ============================================================================
93110
// SUBMISSIONS
94111
// ============================================================================

crates/platform-server/src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ async fn main() -> anyhow::Result<()> {
152152
"/api/v1/validators/heartbeat",
153153
post(api::validators::heartbeat),
154154
)
155+
.route(
156+
"/api/v1/validators/whitelist",
157+
get(api::validators::get_whitelisted_validators),
158+
)
155159
.route(
156160
"/api/v1/network/state",
157161
get(api::challenges::get_network_state),

crates/platform-server/src/websocket/handler.rs

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! WebSocket connection handler
22
3+
use crate::api::auth::verify_signature;
4+
use crate::db::queries;
35
use crate::models::WsEvent;
46
use crate::state::AppState;
57
use crate::websocket::events::WsConnection;
@@ -8,7 +10,8 @@ use axum::{
810
ws::{Message, WebSocket, WebSocketUpgrade},
911
Query, State,
1012
},
11-
response::Response,
13+
http::StatusCode,
14+
response::{IntoResponse, Response},
1215
};
1316
use futures::{SinkExt, StreamExt};
1417
use serde::Deserialize;
@@ -19,8 +22,13 @@ use uuid::Uuid;
1922

2023
#[derive(Debug, Deserialize)]
2124
pub struct WsQuery {
22-
pub token: Option<String>,
25+
/// Validator hotkey (SS58 format)
2326
pub hotkey: Option<String>,
27+
/// Timestamp for signature verification
28+
pub timestamp: Option<i64>,
29+
/// Signature of "ws_connect:{hotkey}:{timestamp}"
30+
pub signature: Option<String>,
31+
/// Role (validator, miner, etc.)
2432
pub role: Option<String>,
2533
}
2634

@@ -29,6 +37,60 @@ pub async fn ws_handler(
2937
State(state): State<Arc<AppState>>,
3038
Query(query): Query<WsQuery>,
3139
) -> Response {
40+
// Verify authentication if hotkey is provided
41+
if let Some(ref hotkey) = query.hotkey {
42+
let timestamp = match query.timestamp {
43+
Some(ts) => ts,
44+
None => {
45+
warn!(
46+
"WebSocket connection rejected: missing timestamp for hotkey {}",
47+
hotkey
48+
);
49+
return (StatusCode::UNAUTHORIZED, "Missing timestamp").into_response();
50+
}
51+
};
52+
53+
let signature = match &query.signature {
54+
Some(sig) => sig,
55+
None => {
56+
warn!(
57+
"WebSocket connection rejected: missing signature for hotkey {}",
58+
hotkey
59+
);
60+
return (StatusCode::UNAUTHORIZED, "Missing signature").into_response();
61+
}
62+
};
63+
64+
// Verify timestamp is recent (within 5 minutes)
65+
let now = std::time::SystemTime::now()
66+
.duration_since(std::time::UNIX_EPOCH)
67+
.unwrap()
68+
.as_secs() as i64;
69+
70+
if (now - timestamp).abs() > 300 {
71+
warn!(
72+
"WebSocket connection rejected: timestamp too old for hotkey {}",
73+
hotkey
74+
);
75+
return (StatusCode::UNAUTHORIZED, "Timestamp too old").into_response();
76+
}
77+
78+
// Verify signature
79+
let message = format!("ws_connect:{}:{}", hotkey, timestamp);
80+
if !verify_signature(hotkey, &message, signature) {
81+
warn!(
82+
"WebSocket connection rejected: invalid signature for hotkey {}",
83+
hotkey
84+
);
85+
return (StatusCode::UNAUTHORIZED, "Invalid signature").into_response();
86+
}
87+
88+
info!(
89+
"WebSocket authenticated for hotkey: {}",
90+
&hotkey[..16.min(hotkey.len())]
91+
);
92+
}
93+
3294
let conn_id = Uuid::new_v4();
3395
ws.on_upgrade(move |socket| handle_socket(socket, state, conn_id, query))
3496
}
@@ -48,6 +110,15 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, conn_id: Uuid, q
48110
conn_id, query.hotkey, query.role
49111
);
50112

113+
// Register validator in DB when they connect with a hotkey (updates last_seen)
114+
if let Some(ref hotkey) = query.hotkey {
115+
if let Err(e) = queries::upsert_validator(&state.db, hotkey, 0).await {
116+
warn!("Failed to register validator {}: {}", hotkey, e);
117+
} else {
118+
info!("Validator {} registered/updated last_seen", hotkey);
119+
}
120+
}
121+
51122
let mut event_rx = state.broadcaster.subscribe();
52123

53124
let send_task = tokio::spawn(async move {

0 commit comments

Comments
 (0)