Skip to content

Commit 5864c70

Browse files
committed
feat: add subnet_getWeights RPC and HTTP weight fallback for peerless validators
1 parent 161832e commit 5864c70

9 files changed

Lines changed: 251 additions & 42 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bins/validator-node/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ bincode = { workspace = true }
5252
sha2 = { workspace = true }
5353
rand = { workspace = true }
5454

55+
# HTTP client (for weight fallback)
56+
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
57+
5558
# Error tracking
5659
sentry = { version = "0.46", features = ["tracing"] }
5760
sentry-tracing = "0.46"

bins/validator-node/src/main.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3726,6 +3726,43 @@ async fn handle_block_event(
37263726
}
37273727
};
37283728

3729+
// Fallback: if all weights are burn-only, try fetching from
3730+
// the primary validator's RPC (chain.platform.network).
3731+
let all_burn_only = weights_to_submit
3732+
.iter()
3733+
.all(|(_, uids, _)| uids.len() == 1 && uids[0] == 0);
3734+
let weights_to_submit = if all_burn_only {
3735+
info!("All local weights are burn-only, attempting HTTP fallback from chain.platform.network");
3736+
match fetch_remote_weights().await {
3737+
Ok(remote) if !remote.is_empty() => {
3738+
info!(
3739+
"Fetched {} mechanism weight entries from remote",
3740+
remote.len()
3741+
);
3742+
remote
3743+
}
3744+
Ok(_) => {
3745+
warn!("Remote returned empty weights, using local burn-only");
3746+
weights_to_submit
3747+
}
3748+
Err(e) => {
3749+
warn!(
3750+
"Failed to fetch remote weights: {}, using local burn-only",
3751+
e
3752+
);
3753+
weights_to_submit
3754+
}
3755+
}
3756+
} else {
3757+
weights_to_submit
3758+
};
3759+
3760+
// Store computed weights in chain state for the subnet_getWeights RPC
3761+
{
3762+
let mut cs = chain_state.write();
3763+
cs.last_computed_weights = weights_to_submit.clone();
3764+
}
3765+
37293766
for (mechanism_id, uids, weights) in weights_to_submit {
37303767
info!(
37313768
"Submitting weights for mechanism {} ({} UIDs)",
@@ -4006,4 +4043,62 @@ async fn process_wasm_evaluations(
40064043
}
40074044
}
40084045
}
4046+
/// Fetch pre-computed weights from the primary validator's RPC endpoint.
4047+
/// Returns Vec<(mechanism_id, uids, weights)> ready for submission.
4048+
async fn fetch_remote_weights() -> anyhow::Result<Vec<(u8, Vec<u16>, Vec<u16>)>> {
4049+
let url = "https://chain.platform.network/rpc";
4050+
let body = serde_json::json!({
4051+
"jsonrpc": "2.0",
4052+
"method": "subnet_getWeights",
4053+
"params": {},
4054+
"id": 1
4055+
});
4056+
4057+
let client = reqwest::Client::builder()
4058+
.timeout(std::time::Duration::from_secs(10))
4059+
.build()?;
4060+
4061+
let resp = client
4062+
.post(url)
4063+
.header("Content-Type", "application/json")
4064+
.json(&body)
4065+
.send()
4066+
.await?
4067+
.json::<serde_json::Value>()
4068+
.await?;
4069+
4070+
let weights_arr = resp
4071+
.get("result")
4072+
.and_then(|r| r.get("weights"))
4073+
.and_then(|w| w.as_array())
4074+
.ok_or_else(|| anyhow::anyhow!("Invalid response: missing result.weights"))?;
4075+
4076+
let mut result = Vec::new();
4077+
for entry in weights_arr {
4078+
let mechanism_id = entry
4079+
.get("mechanismId")
4080+
.and_then(|v| v.as_u64())
4081+
.unwrap_or(0) as u8;
4082+
let entries = entry
4083+
.get("entries")
4084+
.and_then(|v| v.as_array())
4085+
.ok_or_else(|| anyhow::anyhow!("Missing entries in weight entry"))?;
4086+
4087+
let mut uids = Vec::new();
4088+
let mut vals = Vec::new();
4089+
for e in entries {
4090+
let uid = e.get("uid").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
4091+
let weight = e.get("weight").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
4092+
uids.push(uid);
4093+
vals.push(weight);
4094+
}
4095+
4096+
if !uids.is_empty() {
4097+
result.push((mechanism_id, uids, vals));
4098+
}
4099+
}
4100+
4101+
Ok(result)
4102+
}
4103+
40094104
// Build trigger: 1771754356

crates/core/src/state.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@ pub struct ChainState {
210210
/// Validators that have announced LLM inference capability (Chutes API key)
211211
#[serde(default, with = "hotkey_set_serde")]
212212
pub llm_capable_validators: std::collections::HashSet<Hotkey>,
213+
214+
/// Last computed subnet weights (mechanism_id -> (uids, u16 weights))
215+
/// Updated at each commit window, served via `subnet_getWeights` RPC.
216+
#[serde(skip)]
217+
pub last_computed_weights: Vec<(u8, Vec<u16>, Vec<u16>)>,
213218
}
214219

215220
/// Route information for a challenge
@@ -247,6 +252,7 @@ impl Default for ChainState {
247252
pause_reason: None,
248253
mutation_sequence: 0,
249254
llm_capable_validators: std::collections::HashSet::new(),
255+
last_computed_weights: Vec::new(),
250256
}
251257
}
252258
}
@@ -274,6 +280,7 @@ impl ChainState {
274280
pause_reason: None,
275281
mutation_sequence: 0,
276282
llm_capable_validators: std::collections::HashSet::new(),
283+
last_computed_weights: Vec::new(),
277284
};
278285
state.update_hash();
279286
state

crates/core/src/state_versioning.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ impl ChainStateV1 {
163163
pause_reason: None,
164164
mutation_sequence: 0,
165165
llm_capable_validators: HashSet::new(),
166+
last_computed_weights: Vec::new(),
166167
}
167168
}
168169
}
@@ -218,6 +219,7 @@ impl ChainStateV2 {
218219
pause_reason: None,
219220
mutation_sequence: 0,
220221
llm_capable_validators: HashSet::new(),
222+
last_computed_weights: Vec::new(),
221223
}
222224
}
223225
}

crates/rpc-server/src/auth.rs

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -197,59 +197,74 @@ fn canonicalize_json_value(value: &serde_json::Value) -> String {
197197
}
198198
}
199199

200-
/// Parse and verify a Bearer session token.
200+
/// Verify a session token from the Authorization header.
201201
///
202-
/// Token format: `{hotkey_hex}:{expiry_ms}:{nonce}:{signature_hex}`
203-
/// Signed message: `session:{hotkey_ss58}:{expiry_ms}:{nonce}`
202+
/// Token format: `{session_pubkey_hex}:{timestamp}:{nonce}:{signature_hex}`
204203
///
205-
/// Returns the hotkey hex string if the token is valid.
206-
/// Does NOT check revocation (that requires a WASM call to the auth challenge).
207-
pub fn verify_bearer_token(token: &str) -> Result<BearerTokenInfo, AuthError> {
204+
/// The signature is over:
205+
/// `{method}:{path}:{body_hash}:{timestamp}:{nonce}`
206+
///
207+
/// The session_pubkey is an ephemeral sr25519 public key registered via
208+
/// the auth challenge `/login`. The RPC verifies the signature, then calls
209+
/// the auth WASM challenge `/verify` to resolve the hotkey and check revocation.
210+
///
211+
/// Validators cannot forge requests because they don't have the ephemeral
212+
/// private key. Each request has a unique signature (timestamp + nonce).
213+
pub fn verify_session_token(
214+
token: &str,
215+
method: &str,
216+
path: &str,
217+
body: &[u8],
218+
) -> Result<SessionTokenInfo, AuthError> {
208219
let parts: Vec<&str> = token.splitn(4, ':').collect();
209220
if parts.len() != 4 {
210221
return Err(AuthError::InvalidSignature);
211222
}
212223

213-
let hotkey_hex = parts[0];
214-
let expiry_str = parts[1];
224+
let session_pubkey_hex = parts[0];
225+
let timestamp_str = parts[1];
215226
let nonce = parts[2];
216227
let signature_hex = parts[3];
217228

218-
// Parse hotkey
219-
let hotkey = Hotkey::from_hex(hotkey_hex).ok_or(AuthError::InvalidHotkey)?;
220-
221-
// Parse expiry
222-
let expiry: i64 = expiry_str.parse().map_err(|_| AuthError::InvalidNonce)?;
229+
// Validate session pubkey format (64 hex chars = 32 bytes)
230+
if session_pubkey_hex.len() != 64 {
231+
return Err(AuthError::InvalidHotkey);
232+
}
223233

224-
// Check expiry
225-
let now_ms = chrono::Utc::now().timestamp_millis();
226-
if now_ms > expiry {
234+
// Parse timestamp and check freshness (5 min window)
235+
let timestamp: i64 = timestamp_str.parse().map_err(|_| AuthError::InvalidNonce)?;
236+
if !verify_timestamp(timestamp) {
227237
return Err(AuthError::MessageExpired);
228238
}
229239

230-
// Verify signature
231-
let hotkey_ss58 = hotkey.to_ss58();
232-
let message = format!("session:{}:{}:{}", hotkey_ss58, expiry, nonce);
240+
// Build the signed message and verify
241+
let body_hash = {
242+
let canonical = if let Ok(val) = serde_json::from_slice::<serde_json::Value>(body) {
243+
canonicalize_json_value(&val).into_bytes()
244+
} else {
245+
body.to_vec()
246+
};
247+
hex::encode(Sha256::digest(&canonical))
248+
};
249+
250+
let message = format!("{}:{}:{}:{}:{}", method, path, body_hash, timestamp, nonce);
233251

234-
match verify_validator_signature(hotkey_hex, &message, signature_hex)? {
235-
true => Ok(BearerTokenInfo {
236-
hotkey_hex: hotkey_hex.to_string(),
237-
expiry,
238-
nonce: nonce.to_string(),
252+
match verify_validator_signature(session_pubkey_hex, &message, signature_hex)? {
253+
true => Ok(SessionTokenInfo {
254+
session_pubkey_hex: session_pubkey_hex.to_string(),
239255
}),
240256
false => Err(AuthError::VerificationFailed),
241257
}
242258
}
243259

244-
/// Parsed and verified bearer token info.
245-
pub struct BearerTokenInfo {
246-
pub hotkey_hex: String,
247-
pub expiry: i64,
248-
pub nonce: String,
260+
/// Parsed and verified session token info.
261+
pub struct SessionTokenInfo {
262+
pub session_pubkey_hex: String,
249263
}
250264

251-
/// Extract Bearer token from Authorization header.
252-
pub fn extract_bearer_token(headers: &HashMap<String, String>) -> Option<String> {
265+
/// Extract session token from Authorization header.
266+
/// Supports: `Authorization: Session {token}`
267+
pub fn extract_session_token(headers: &HashMap<String, String>) -> Option<String> {
253268
let headers_lower: HashMap<String, String> = headers
254269
.iter()
255270
.map(|(k, v)| (k.to_lowercase(), v.clone()))
@@ -258,8 +273,8 @@ pub fn extract_bearer_token(headers: &HashMap<String, String>) -> Option<String>
258273
headers_lower
259274
.get("authorization")
260275
.and_then(|v| {
261-
v.strip_prefix("Bearer ")
262-
.or_else(|| v.strip_prefix("bearer "))
276+
v.strip_prefix("Session ")
277+
.or_else(|| v.strip_prefix("session "))
263278
})
264279
.map(|t| t.trim().to_string())
265280
}

crates/rpc-server/src/jsonrpc.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@ impl RpcHandler {
319319
["epoch", "current"] => self.epoch_current(req.id),
320320
["epoch", "getPhase"] => self.epoch_get_phase(req.id),
321321

322+
// Subnet namespace
323+
["subnet", "getWeights"] => self.subnet_get_weights(req.id),
324+
322325
// Leaderboard namespace
323326
["leaderboard", "get"] => self.leaderboard_get(req.id, req.params),
324327

@@ -376,6 +379,8 @@ impl RpcHandler {
376379
"job_list", "job_get",
377380
// Epoch
378381
"epoch_current", "epoch_getPhase",
382+
// Subnet
383+
"subnet_getWeights",
379384
// Leaderboard
380385
"leaderboard_get",
381386
// Evaluation
@@ -1451,6 +1456,43 @@ impl RpcHandler {
14511456
JsonRpcResponse::result(id, json!(phase))
14521457
}
14531458

1459+
// ==================== Subnet Namespace ====================
1460+
1461+
fn subnet_get_weights(&self, id: Value) -> JsonRpcResponse {
1462+
let chain = self.chain_state.read();
1463+
let weights = &chain.last_computed_weights;
1464+
1465+
if weights.is_empty() {
1466+
return JsonRpcResponse::result(
1467+
id,
1468+
json!({
1469+
"weights": [],
1470+
"message": "No weights computed yet. Weights are calculated at each commit window."
1471+
}),
1472+
);
1473+
}
1474+
1475+
let entries: Vec<Value> = weights
1476+
.iter()
1477+
.map(|(mechanism_id, uids, vals)| {
1478+
let pairs: Vec<Value> = uids
1479+
.iter()
1480+
.zip(vals.iter())
1481+
.map(|(uid, val)| json!({"uid": uid, "weight": val}))
1482+
.collect();
1483+
let total: u64 = vals.iter().map(|v| *v as u64).sum();
1484+
json!({
1485+
"mechanismId": mechanism_id,
1486+
"entries": pairs,
1487+
"totalWeight": total,
1488+
"count": uids.len(),
1489+
})
1490+
})
1491+
.collect();
1492+
1493+
JsonRpcResponse::result(id, json!({ "weights": entries }))
1494+
}
1495+
14541496
// ==================== Leaderboard Namespace ====================
14551497

14561498
fn leaderboard_get(&self, id: Value, params: Value) -> JsonRpcResponse {

0 commit comments

Comments
 (0)