Skip to content
Merged
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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
#[serde(rename = "lastUpdated", skip_serializing_if = "Option::is_none")]
pub last_updated: Option<i64>,
}

impl<T> ApiResponse<T> {
Expand All @@ -32,6 +34,7 @@ impl<T> ApiResponse<T> {
success: true,
data: Some(data),
message: None,
last_updated: None,
}
}

Expand All @@ -40,6 +43,7 @@ impl<T> ApiResponse<T> {
success: false,
data: None,
message: Some(message),
last_updated: None,
}
}
}
Expand Down Expand Up @@ -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<Json<ApiResponse<EvoMasternodeList>>, 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
)))),
}
}

107 changes: 106 additions & 1 deletion src/masternode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<String, Vec<String>>>,
pub payee: String,
pub status: String,
#[serde(rename = "type")]
Expand Down Expand Up @@ -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<HashMap<String, Vec<String>>>,
pub status: String,
#[serde(rename = "platformNodeID", skip_serializing_if = "Option::is_none")]
pub platform_node_id: Option<String>,
#[serde(rename = "platformP2PPort", skip_serializing_if = "Option::is_none")]
pub platform_p2p_port: Option<u16>,
#[serde(rename = "platformHTTPPort", skip_serializing_if = "Option::is_none")]
pub platform_http_port: Option<u16>,
#[serde(rename = "versionCheck")]
Expand All @@ -63,6 +71,9 @@ impl From<MasternodeInfo> for Option<EvoMasternodeInfo> {
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,
Expand All @@ -76,4 +87,98 @@ impl From<MasternodeInfo> for Option<EvoMasternodeInfo> {
}

pub type MasternodeList = HashMap<String, MasternodeInfo>;
pub type EvoMasternodeList = Vec<EvoMasternodeInfo>;
pub type EvoMasternodeList = Vec<EvoMasternodeInfo>;

#[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::<EvoMasternodeInfo>::from(
serde_json::from_value::<MasternodeInfo>(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::<EvoMasternodeInfo>::from(
serde_json::from_value::<MasternodeInfo>(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::<EvoMasternodeInfo>::from(
serde_json::from_value::<MasternodeInfo>(entry.clone()).unwrap(),
)
.unwrap();
assert_eq!(node.status, "POSE_BANNED");
assert_eq!(node.version_check, "fail");
entry["type"] = json!("Regular");
assert!(Option::<EvoMasternodeInfo>::from(
serde_json::from_value::<MasternodeInfo>(entry).unwrap(),
)
.is_none());
}
}
Loading