From 63e1ec85b3feac168aa177436778b8a324048b90 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 20:36:31 -0500 Subject: [PATCH] feat: expose platform bootstrap metadata in masternode responses --- README.md | 40 ++++++++++++ src/api.rs | 34 ++++++++-- src/masternode.rs | 107 ++++++++++++++++++++++++++++++- src/masternode_cache.rs | 138 ++++++++++++++++++++++++++++++++++++---- 4 files changed, 298 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 4f754c1..d7527dc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,46 @@ A Rust-based HTTP API server that provides RESTful endpoints for managing Dash L - `GET /quorums/{hash}` - Get specific quorum by hash - `GET /previous` - Get quorums from previous blocks (configurable offset) +### Masternode bootstrap metadata + +`GET /masternodes` preserves the existing `data` array and adds `lastUpdated` to +successful responses. It is the Unix timestamp in seconds when the last +successfully cached DML fetch began. Repeated reads and failed refreshes do not +advance it. The list and timestamp are published together; the endpoint performs +no RPC calls or node probes. Clients should check both `success` and freshness +before using the data (the refresh interval is ten minutes). + +Each evonode now includes `platformNodeID` and `platformP2PPort` when Core supplies +them, alongside the existing `platformHTTPPort`. On Core versions that supply +separate service endpoints, `addresses` preserves the `core_p2p`, `platform_p2p`, +and `platform_https` arrays, including non-default ports and IPv6 addresses. +Prefer `addresses.platform_p2p` when present; otherwise use the host in `address` +with `platformP2PPort`. Missing fields are omitted rather than guessed. Configured +address host overrides apply to these endpoint arrays as well as `address`. + +```json +{ + "success": true, + "data": [{ + "proTxHash": "...", + "address": "192.0.2.1:9999", + "addresses": {"platform_p2p": ["192.0.2.2:27656"]}, + "status": "ENABLED", + "platformNodeID": "...", + "platformP2PPort": 27656, + "platformHTTPPort": 443, + "versionCheck": "success" + }], + "message": null, + "lastUpdated": 1788828000 +} +``` + +The timestamp describes cache freshness, not Core synchronization or a successful +Tenderdash P2P handshake. Consumers still need to exclude banned entries and +validate the node ID and endpoint fields. Existing clients can continue reading +`data` unchanged. + ## Configuration ### config.toml diff --git a/src/api.rs b/src/api.rs index 9cf8c0a..7b8d6f7 100644 --- a/src/api.rs +++ b/src/api.rs @@ -24,6 +24,8 @@ pub struct ApiResponse { pub success: bool, pub data: Option, pub message: Option, + #[serde(rename = "lastUpdated", skip_serializing_if = "Option::is_none")] + pub last_updated: Option, } impl ApiResponse { @@ -32,6 +34,7 @@ impl ApiResponse { success: true, data: Some(data), message: None, + last_updated: None, } } @@ -40,6 +43,7 @@ impl ApiResponse { success: false, data: None, message: Some(message), + last_updated: None, } } } @@ -184,21 +188,37 @@ async fn get_previous_quorums( #[axum::debug_handler] async fn get_masternodes( - State((_, config, masternode_cache)): State<(SharedQuorumCache, SharedConfig, SharedMasternodeCache)>, + State((_, config, masternode_cache)): State<( + SharedQuorumCache, + SharedConfig, + SharedMasternodeCache, + )>, ) -> Result>, StatusCode> { - match masternode_cache.get_masternodes().await { - Ok(masternodes) => { + match masternode_cache.get_snapshot().await { + Ok(snapshot) => { // Apply address host override if configured - let masternodes: EvoMasternodeList = masternodes + let masternodes: EvoMasternodeList = snapshot + .masternodes .into_iter() .map(|mut m| { m.address = config.apply_address_host_override(&m.address); + if let Some(addresses) = &mut m.addresses { + for endpoints in addresses.values_mut() { + for endpoint in endpoints { + *endpoint = config.apply_address_host_override(endpoint); + } + } + } m }) .collect(); - Ok(Json(ApiResponse::success(masternodes))) + let mut response = ApiResponse::success(masternodes); + response.last_updated = Some(snapshot.last_updated); + Ok(Json(response)) } - Err(e) => Ok(Json(ApiResponse::error(format!("Failed to load masternodes: {}", e)))) + Err(e) => Ok(Json(ApiResponse::error(format!( + "Failed to load masternodes: {}", + e + )))), } } - diff --git a/src/masternode.rs b/src/masternode.rs index 9b85140..2219f8c 100644 --- a/src/masternode.rs +++ b/src/masternode.rs @@ -6,6 +6,8 @@ pub struct MasternodeInfo { #[serde(rename = "proTxHash")] pub pro_tx_hash: String, pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub addresses: Option>>, pub payee: String, pub status: String, #[serde(rename = "type")] @@ -39,7 +41,13 @@ pub struct EvoMasternodeInfo { #[serde(rename = "proTxHash")] pub pro_tx_hash: String, pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub addresses: Option>>, pub status: String, + #[serde(rename = "platformNodeID", skip_serializing_if = "Option::is_none")] + pub platform_node_id: Option, + #[serde(rename = "platformP2PPort", skip_serializing_if = "Option::is_none")] + pub platform_p2p_port: Option, #[serde(rename = "platformHTTPPort", skip_serializing_if = "Option::is_none")] pub platform_http_port: Option, #[serde(rename = "versionCheck")] @@ -63,6 +71,9 @@ impl From for Option { Some(EvoMasternodeInfo { pro_tx_hash: info.pro_tx_hash, address: info.address, + addresses: info.addresses, + platform_node_id: info.platform_node_id, + platform_p2p_port: info.platform_p2p_port, status: info.status, platform_http_port: info.platform_http_port, version_check, @@ -76,4 +87,98 @@ impl From for Option { } pub type MasternodeList = HashMap; -pub type EvoMasternodeList = Vec; \ No newline at end of file +pub type EvoMasternodeList = Vec; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn dml_entry() -> serde_json::Value { + json!({ + "proTxHash": "11".repeat(32), + "address": "192.0.2.1:9999", + "payee": "payee", + "status": "ENABLED", + "type": "Evo", + "platformNodeID": "22".repeat(20), + "platformP2PPort": 27656, + "platformHTTPPort": 1443, + "pospenaltyscore": 0, + "consecutivePayments": 0, + "lastpaidtime": 0, + "lastpaidblock": 0, + "owneraddress": "owner", + "votingaddress": "voter", + "collateraladdress": "collateral", + "pubkeyoperator": "operator" + }) + } + + #[test] + fn should_preserve_platform_identity_ports_and_separate_addresses_from_dml() { + let mut entry = dml_entry(); + entry["addresses"] = json!({ + "core_p2p": ["192.0.2.1:9999"], + "platform_p2p": ["192.0.2.2:27656", "[2001:db8::1]:27656"], + "platform_https": ["192.0.2.3:1443"] + }); + let node = Option::::from( + serde_json::from_value::(entry.clone()).unwrap(), + ) + .unwrap(); + let response = serde_json::to_value(node).unwrap(); + for field in [ + "platformNodeID", + "platformP2PPort", + "platformHTTPPort", + "addresses", + "address", + "proTxHash", + ] { + assert_eq!( + response[field], entry[field], + "{field} was lost in conversion" + ); + } + } + + #[test] + fn should_preserve_legacy_responses_without_inventing_platform_fields() { + let mut entry = dml_entry(); + for field in ["platformNodeID", "platformP2PPort", "platformHTTPPort"] { + entry.as_object_mut().unwrap().remove(field); + } + let node = Option::::from( + serde_json::from_value::(entry).unwrap(), + ) + .unwrap(); + let response = serde_json::to_value(node).unwrap(); + for field in [ + "platformNodeID", + "platformP2PPort", + "platformHTTPPort", + "addresses", + ] { + assert!(response.get(field).is_none()); + } + assert_eq!(response["address"], "192.0.2.1:9999"); + } + + #[test] + fn should_keep_ban_status_and_continue_excluding_regular_masternodes() { + let mut entry = dml_entry(); + entry["status"] = json!("POSE_BANNED"); + let node = Option::::from( + serde_json::from_value::(entry.clone()).unwrap(), + ) + .unwrap(); + assert_eq!(node.status, "POSE_BANNED"); + assert_eq!(node.version_check, "fail"); + entry["type"] = json!("Regular"); + assert!(Option::::from( + serde_json::from_value::(entry).unwrap(), + ) + .is_none()); + } +} diff --git a/src/masternode_cache.rs b/src/masternode_cache.rs index 7e5c72d..70f89bb 100644 --- a/src/masternode_cache.rs +++ b/src/masternode_cache.rs @@ -2,14 +2,20 @@ use crate::config::Config; use crate::grpc_client; use crate::masternode::EvoMasternodeList; use crate::masternode_loader; -use chrono::Local; +use chrono::{Local, Utc}; use std::sync::{Arc, RwLock}; -use std::time::{Duration, Instant}; -use tokio::sync::Mutex; +use std::time::Duration; + +/// The registry data and its successful fetch time are published atomically. +#[derive(Clone)] +pub struct MasternodeSnapshot { + pub masternodes: EvoMasternodeList, + /// Unix seconds, captured before loading the registry (not when serving it). + pub last_updated: i64, +} pub struct MasternodeCache { - data: Arc>>, - last_update: Arc>>, + data: Arc>>, config: Arc, update_interval: Duration, } @@ -18,7 +24,6 @@ impl MasternodeCache { pub fn new(config: Config) -> Self { Self { data: Arc::new(RwLock::new(None)), - last_update: Arc::new(Mutex::new(None)), config: Arc::new(config), update_interval: Duration::from_secs(600), // 10 minutes } @@ -31,6 +36,13 @@ impl MasternodeCache { pub async fn get_masternodes( &self, ) -> Result> { + Ok(self.get_snapshot().await?.masternodes) + } + + /// Read registry data and freshness from the same cached snapshot, without RPC. + pub async fn get_snapshot( + &self, + ) -> Result> { let data = self.data.read().map_err(|_| "Failed to read cache")?; Ok(data .as_ref() @@ -66,6 +78,9 @@ impl MasternodeCache { } async fn update_cache_internal(&self) -> Result<(), Box> { + // Only publish this time if loading and probing the new list succeed. + let last_updated = Utc::now().timestamp(); + // Fetch new data let mut masternodes = masternode_loader::load_masternode_list(&self.config).await?; @@ -178,13 +193,10 @@ impl MasternodeCache { // Update the cache { let mut data = self.data.write().map_err(|_| "Failed to write to cache")?; - *data = Some(masternodes); - } - - // Update the timestamp - { - let mut last_update = self.last_update.lock().await; - *last_update = Some(Instant::now()); + *data = Some(MasternodeSnapshot { + masternodes, + last_updated, + }); } println!("Masternode cache updated successfully"); @@ -215,3 +227,103 @@ impl MasternodeCache { }); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{api, masternode::EvoMasternodeInfo, quorum_cache::QuorumCache}; + use axum::{ + body::{to_bytes, Body}, + http::Request, + }; + use serde_json::{json, Value}; + use tower::Service; + + fn snapshot() -> MasternodeSnapshot { + MasternodeSnapshot { + last_updated: 1234567890, + masternodes: vec![EvoMasternodeInfo { + pro_tx_hash: "11".repeat(32), + address: "192.0.2.1:9999".into(), + addresses: Some(std::collections::HashMap::from([ + ("platform_p2p".into(), vec!["192.0.2.2:27656".into()]), + ("platform_https".into(), vec!["192.0.2.3:1443".into()]), + ])), + status: "ENABLED".into(), + platform_node_id: Some("22".repeat(20)), + platform_p2p_port: Some(27656), + platform_http_port: Some(1443), + version_check: "success".into(), + dapi_version: None, + drive_version: None, + }], + } + } + + async fn response(config: Config, cache: Arc, uri: &str) -> Value { + let mut router = + api::create_router(Arc::new(QuorumCache::new(config.clone())), config, cache); + let response = router + .call(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap() + } + + #[tokio::test] + async fn should_serve_fields_and_original_refresh_time_without_rpc() { + let mut config = Config::default(); + // Even an accidentally attempted RPC cannot reach the network. + config.rpc.url = "not a URL".into(); + let cache = Arc::new(MasternodeCache::new(config.clone())); + *cache.data.write().unwrap() = Some(snapshot()); + let first = response(config.clone(), cache.clone(), "/masternodes").await; + assert_eq!(first["success"], true); + assert_eq!(first["lastUpdated"], 1234567890); + assert!(first["data"].is_array()); + assert_eq!(first["data"][0]["platformNodeID"], "22".repeat(20)); + assert_eq!(first["data"][0]["platformP2PPort"], 27656); + assert_eq!( + first["data"][0]["addresses"]["platform_p2p"], + json!(["192.0.2.2:27656"]) + ); + // A failed refresh must not stamp the old list with a new timestamp. + assert!(cache.refresh().await.is_err()); + assert_eq!(response(config, cache, "/masternodes").await, first); + } + + #[tokio::test] + async fn should_apply_host_overrides_to_separate_endpoints_without_changing_cache() { + let mut config = Config::default(); + config.docker.address_host_override = Some("127.0.0.1".into()); + let cache = Arc::new(MasternodeCache::new(config.clone())); + *cache.data.write().unwrap() = Some(snapshot()); + let result = response(config, cache.clone(), "/masternodes").await; + assert_eq!(result["data"][0]["address"], "127.0.0.1:9999"); + assert_eq!( + result["data"][0]["addresses"]["platform_p2p"], + json!(["127.0.0.1:27656"]) + ); + assert_eq!( + result["data"][0]["addresses"]["platform_https"], + json!(["127.0.0.1:1443"]) + ); + assert_eq!( + cache.get_snapshot().await.unwrap().masternodes[0].address, + "192.0.2.1:9999" + ); + } + + #[tokio::test] + async fn should_omit_freshness_when_cache_is_empty_and_on_other_endpoints() { + let config = Config::default(); + let cache = Arc::new(MasternodeCache::new(config.clone())); + let result = response(config.clone(), cache.clone(), "/masternodes").await; + assert_eq!(result["success"], false); + assert!(result.get("lastUpdated").is_none()); + let health = response(config, cache, "/health").await; + assert_eq!(health["success"], true); + assert!(health.get("lastUpdated").is_none()); + } +}