diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 900a5b07e73..18e4eb25ad4 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1050,7 +1050,13 @@ pub(crate) struct MasternodeAggregate { /// Latest known service endpoint `"ip:port"` (latest-height update /// wins; seeded by the ProRegTx address). pub service_address: Option, - /// Height that set `service_address` (drives latest-wins). + /// Platform HTTP (DAPI gRPC) port from the same ProRegTx / ProUpServTx + /// that set `service_address` — evonodes only, `None` for a regular + /// masternode or a pre-v19 payload without platform fields. With the + /// service IP this addresses the node's DAPI (`https://:`). + pub platform_http_port: Option, + /// Height that set `service_address` / `platform_http_port` (drives + /// latest-wins). service_height: u32, /// evonode / HPMN flag from the ProRegTx `masternode_type`. pub is_evonode: bool, @@ -1183,6 +1189,7 @@ where // treat both as updates observed at this height. if agg.service_address.is_none() || height >= agg.service_height { agg.service_address = Some(p.service_address.to_string()); + agg.platform_http_port = p.platform_http_port; agg.service_height = height; } if agg.voting_key_hash.is_none() || height >= agg.voting_height { @@ -1217,6 +1224,7 @@ where Some(TransactionPayload::ProviderUpdateServicePayloadType(p)) => { if agg.service_address.is_none() || height >= agg.service_height { agg.service_address = Some(provider_ip_port(p.ip_address, p.port)); + agg.platform_http_port = p.platform_http_port; agg.service_height = height; } // ProUpServ's `platform_node_id` is now `Option` @@ -1331,6 +1339,11 @@ pub struct MasternodeEntryFFI { pub has_voting_key_hash: bool, /// Service endpoint `"ip:port"`, or null. pub service_address: *mut c_char, + /// Platform HTTP (DAPI gRPC) port from the latest ProRegTx / ProUpServTx, + /// gated by `has_platform_http_port` (evonodes only). Together with the + /// `service_address` host this addresses the node's DAPI. + pub platform_http_port: u16, + pub has_platform_http_port: bool, /// Base58 owner / voting P2PKH addresses for the wallet's network /// (null when the hash is absent) — the app-layer join key against a /// provider-key account's persisted base58 address, so Swift never @@ -1497,6 +1510,8 @@ pub(crate) fn masternode_entry_ffi( voting_key_hash: mn.voting_key_hash.unwrap_or([0u8; 20]), has_voting_key_hash: mn.voting_key_hash.is_some(), service_address, + platform_http_port: mn.platform_http_port.unwrap_or(0), + has_platform_http_port: mn.platform_http_port.is_some(), owner_address, voting_address, operator_public_key: mn.operator_public_key.unwrap_or([0u8; 48]), @@ -1989,6 +2004,10 @@ mod tests { mn.platform_node_id.is_none(), "legacy regular-MN fixture has no platform node id" ); + assert!( + mn.platform_http_port.is_none(), + "legacy regular-MN fixture has no platform HTTP port" + ); assert_eq!(mn.tx_count, 1); } @@ -2203,4 +2222,82 @@ mod tests { assert_eq!(mns[0].tx_count, 2, "both updates counted"); } } + + /// The platform HTTP port travels with the service endpoint: the ProRegTx + /// seeds it and a later ProUpServTx replaces it (latest-wins), so the + /// DAPI address the wallet builds follows the node's current config. + #[test] + fn platform_http_port_follows_the_service_update() { + use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; + use dashcore::transaction::special_transaction::provider_registration::ProviderMasternodeType; + use dashcore::transaction::TransactionPayload; + + let mut reg = decode_tx(PROREG_HEX); + if let Some(TransactionPayload::ProviderRegistrationPayloadType(p)) = + &mut reg.special_transaction_payload + { + p.masternode_type = ProviderMasternodeType::HighPerformance; + p.platform_http_port = Some(443); + } + let pro_tx_hash = reg.txid(); + + let upserv = dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some( + TransactionPayload::ProviderUpdateServicePayloadType( + ProviderUpdateServicePayload { + version: 2, + mn_type: Some(1), // HighPerformance (evonode) + pro_tx_hash, + ip_address: 42, + port: 19999, + script_payout: dashcore::ScriptBuf::new(), + inputs_hash: [7u8; 32].into(), + platform_node_id: None, + platform_p2p_port: Some(36656), + platform_http_port: Some(1443), + payload_sig: [0u8; 96].into(), + }, + ), + ), + }; + + // Registration alone ⇒ the ProRegTx port. + let mns = aggregate_masternodes([(100u32, 0u32, ®)].into_iter(), unavailable_dml); + assert_eq!(mns.len(), 1); + assert_eq!(mns[0].platform_http_port, Some(443)); + + // A later ProUpServTx replaces it along with the service address. + let mns = aggregate_masternodes( + [(100u32, 0u32, ®), (200u32, 0u32, &upserv)].into_iter(), + unavailable_dml, + ); + assert_eq!(mns.len(), 1, "same proTxHash ⇒ one bucket"); + assert_eq!(mns[0].platform_http_port, Some(1443)); + assert!( + mns[0] + .service_address + .as_deref() + .unwrap_or_default() + .ends_with(":19999"), + "service address and platform port move together" + ); + + // The FFI entry carries it gated by `has_platform_http_port`. + let entry = masternode_entry_ffi( + &mns[0], + 0, + dashcore::Network::Testnet, + &std::collections::HashMap::new(), + &std::collections::HashMap::new(), + ); + assert!(entry.has_platform_http_port); + assert_eq!(entry.platform_http_port, 1443); + // Release the entry's heap C strings through the public free routine. + let entries = Box::into_raw(vec![entry].into_boxed_slice()) as *mut MasternodeEntryFFI; + unsafe { crate::wallet::platform_wallet_manager_free_masternodes(entries, 1) }; + } } diff --git a/packages/rs-sdk-ffi/src/evonode/queries/mod.rs b/packages/rs-sdk-ffi/src/evonode/queries/mod.rs index 6ed5f98a46a..5a7f60b2839 100644 --- a/packages/rs-sdk-ffi/src/evonode/queries/mod.rs +++ b/packages/rs-sdk-ffi/src/evonode/queries/mod.rs @@ -1,7 +1,9 @@ // Evonode queries pub mod proposed_epoch_blocks_by_ids; pub mod proposed_epoch_blocks_by_range; +pub mod status; // Re-export all public functions for convenient access pub use proposed_epoch_blocks_by_ids::dash_sdk_evonode_get_proposed_epoch_blocks_by_ids; pub use proposed_epoch_blocks_by_range::dash_sdk_evonode_get_proposed_epoch_blocks_by_range; +pub use status::dash_sdk_evonode_get_status; diff --git a/packages/rs-sdk-ffi/src/evonode/queries/status.rs b/packages/rs-sdk-ffi/src/evonode/queries/status.rs new file mode 100644 index 00000000000..c38769720d9 --- /dev/null +++ b/packages/rs-sdk-ffi/src/evonode/queries/status.rs @@ -0,0 +1,475 @@ +//! Status of one evonode, asked of that node directly (DAPI `getStatus`). + +use crate::types::SDKHandle; +use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; +use dash_sdk::dapi_client::{Address, RequestSettings}; +use dash_sdk::platform::types::evonode::EvoNode; +use dash_sdk::platform::FetchUnproved; +use dash_sdk::query_types::evonode_status::EvoNodeStatus; +use serde_json::{json, Value}; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::time::Duration; + +/// Ask a single evonode for its DAPI `getStatus` self-report. +/// +/// Unlike the other queries this does NOT go through the SDK's address +/// list: the request is sent to `address` only, over a one-connection pool +/// that is dropped after the call, with no failover to another node. The +/// response is unproved by nature — it is the node describing itself. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance +/// * `address` - DAPI URI of the node, e.g. `https://203.0.113.7:443` +/// +/// # Returns +/// A JSON object mirroring `EvoNodeStatus`, every field the node returned: +/// +/// ```json +/// {"version":{"software":{"dapi":"…","drive":"…","tenderdash":"…"}, +/// "protocol":{"tenderdash":{"p2p":9,"block":14}, +/// "drive":{"latest":9,"current":9,"nextEpoch":9}}}, +/// "node":{"id":"","proTxHash":""}, +/// "chain":{"catchingUp":false,"latestBlockHash":"","latestAppHash":"", +/// "earliestBlockHash":"","earliestAppHash":"", +/// "latestBlockHeight":…,"earliestBlockHeight":…,"maxPeerBlockHeight":…, +/// "coreChainLockedHeight":…}, +/// "network":{"chainId":"…","peersCount":…,"listening":true}, +/// "stateSync":{"totalSyncedTime":…,"remainingTime":…,"totalSnapshots":…, +/// "chunkProcessAvgTime":…,"snapshotHeight":…,"snapshotChunksCount":…, +/// "backfilledBlocks":…,"backfillBlocksTotal":…}, +/// "time":{"local":…,"block":…,"genesis":…,"epoch":…}} +/// ``` +/// +/// Hashes and ids are hex. Optional protobuf fields the node omitted are +/// `null`. Timestamps are passed through exactly as the node sent them: +/// `time.block` / `time.genesis` are Unix milliseconds (Drive's `time_ms`; +/// Drive sends `0` when it has no genesis info), while `time.local` is Unix +/// seconds from rs-dapi and milliseconds from the legacy JS DAPI. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized SDKHandle. +/// - `address` must be a valid NUL-terminated C string for the duration of the call. +/// - On success the returned C string pointer must be freed by the caller with `dash_sdk_string_free`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_evonode_get_status( + sdk_handle: *const SDKHandle, + address: *const c_char, +) -> DashSDKResult { + match get_evonode_status(sdk_handle, address) { + Ok(json) => { + let c_str = match CString::new(json) { + Ok(s) => s, + Err(e) => { + return DashSDKResult { + data_type: DashSDKResultDataType::NoData, + data: std::ptr::null_mut(), + error: Box::into_raw(Box::new(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!("Failed to create CString: {}", e), + ))), + } + } + }; + DashSDKResult { + data_type: DashSDKResultDataType::String, + data: c_str.into_raw() as *mut c_void, + error: std::ptr::null_mut(), + } + } + Err((code, message)) => DashSDKResult { + data_type: DashSDKResultDataType::NoData, + data: std::ptr::null_mut(), + error: Box::into_raw(Box::new(DashSDKError::new(code, message))), + }, + } +} + +fn get_evonode_status( + sdk_handle: *const SDKHandle, + address: *const c_char, +) -> Result { + if sdk_handle.is_null() { + return Err(( + DashSDKErrorCode::InvalidParameter, + "SDK handle is null".to_string(), + )); + } + if address.is_null() { + return Err(( + DashSDKErrorCode::InvalidParameter, + "Address is null".to_string(), + )); + } + + let address_str = unsafe { + CStr::from_ptr(address).to_str().map_err(|e| { + ( + DashSDKErrorCode::InvalidParameter, + format!("Invalid UTF-8 in address: {}", e), + ) + })? + }; + let address: Address = address_str.parse().map_err(|e| { + ( + DashSDKErrorCode::InvalidParameter, + format!("Invalid evonode address '{}': {}", address_str, e), + ) + })?; + + let rt = crate::runtime::BigStackRuntime::new_isolated().map_err(|e| { + ( + DashSDKErrorCode::InternalError, + format!("Failed to create Tokio runtime: {}", e), + ) + })?; + + let wrapper = unsafe { &*(sdk_handle as *const crate::sdk::SDKWrapper) }; + let sdk = wrapper.sdk.clone(); + + // One node, asked once (plus a single retry): the SDK's default retry + // budget is sized for rotating through an address list, which this + // request never does — every retry would hit the same unreachable node. + // Bound the TCP connect too: the SDK default leaves it to the OS + // (~75 s on Apple platforms), which would make an offline node look like + // a hang. Don't ban the address either; it is not in the SDK's pool. + let settings = RequestSettings { + connect_timeout: Some(Duration::from_secs(10)), + timeout: Some(Duration::from_secs(15)), + retries: Some(1), + ban_failed_address: Some(false), + ..RequestSettings::default() + }; + + rt.block_on(async move { + match EvoNodeStatus::fetch_unproved_with_settings(&sdk, EvoNode::new(address), settings) + .await + { + Ok((Some(status), _metadata)) => Ok(evonode_status_json(&status).to_string()), + Ok((None, _metadata)) => Err(( + DashSDKErrorCode::NotFound, + "The evonode returned no status".to_string(), + )), + Err(e) => Err(( + DashSDKErrorCode::NetworkError, + format!("Failed to fetch evonode status: {}", e), + )), + } + }) +} + +/// Serialize every `EvoNodeStatus` field. Kept separate from the FFI entry +/// point so the wire shape is unit-testable without a network. +fn evonode_status_json(status: &EvoNodeStatus) -> Value { + let version = &status.version; + let node = &status.node; + let chain = &status.chain; + let network = &status.network; + let state_sync = &status.state_sync; + let time = &status.time; + + json!({ + "version": { + "software": version.software.as_ref().map(|s| json!({ + "dapi": s.dapi, + "drive": s.drive, + "tenderdash": s.tenderdash, + })), + "protocol": version.protocol.as_ref().map(|p| json!({ + "tenderdash": p.tenderdash.as_ref().map(|t| json!({ + "p2p": t.p2p, + "block": t.block, + })), + "drive": p.drive.as_ref().map(|d| json!({ + "latest": d.latest, + "current": d.current, + "nextEpoch": d.next_epoch, + })), + })), + }, + "node": { + "id": hex::encode(&node.id), + "proTxHash": node.pro_tx_hash.as_ref().map(hex::encode), + }, + "chain": { + "catchingUp": chain.catching_up, + "latestBlockHash": hex::encode(&chain.latest_block_hash), + "latestAppHash": hex::encode(&chain.latest_app_hash), + "earliestBlockHash": hex::encode(&chain.earliest_block_hash), + "earliestAppHash": hex::encode(&chain.earliest_app_hash), + "latestBlockHeight": chain.latest_block_height, + "earliestBlockHeight": chain.earliest_block_height, + "maxPeerBlockHeight": chain.max_peer_block_height, + "coreChainLockedHeight": chain.core_chain_locked_height, + }, + "network": { + "chainId": network.chain_id, + "peersCount": network.peers_count, + "listening": network.listening, + }, + "stateSync": { + "totalSyncedTime": state_sync.total_synced_time, + "remainingTime": state_sync.remaining_time, + "totalSnapshots": state_sync.total_snapshots, + "chunkProcessAvgTime": state_sync.chunk_process_avg_time, + "snapshotHeight": state_sync.snapshot_height, + "snapshotChunksCount": state_sync.snapshot_chunks_count, + "backfilledBlocks": state_sync.backfilled_blocks, + "backfillBlocksTotal": state_sync.backfill_blocks_total, + }, + "time": { + "local": time.local, + "block": time.block, + "genesis": time.genesis, + "epoch": time.epoch, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; + use dash_sdk::query_types::evonode_status::{ + Chain, DriveProtocol, Network, Node, Protocol, Software, StateSync, TenderdashProtocol, + Time, Version, + }; + + #[test] + fn test_get_evonode_status_null_handle() { + unsafe { + let address = CString::new("https://127.0.0.1:1").unwrap(); + let result = dash_sdk_evonode_get_status(std::ptr::null(), address.as_ptr()); + assert!(!result.error.is_null()); + assert_eq!( + (*result.error).code, + DashSDKErrorCode::InvalidParameter, + "a null handle is a caller error, not a network failure" + ); + crate::dash_sdk_error_free(result.error); + } + } + + #[test] + fn test_get_evonode_status_null_address() { + let handle = create_mock_sdk_handle(); + unsafe { + let result = dash_sdk_evonode_get_status(handle, std::ptr::null()); + assert!(!result.error.is_null()); + assert_eq!((*result.error).code, DashSDKErrorCode::InvalidParameter); + crate::dash_sdk_error_free(result.error); + destroy_mock_sdk_handle(handle); + } + } + + /// An address without a host can never be contacted — reject it before + /// touching the network instead of reporting a misleading transport error. + #[test] + fn test_get_evonode_status_invalid_address() { + let handle = create_mock_sdk_handle(); + unsafe { + let address = CString::new("not a uri").unwrap(); + let result = dash_sdk_evonode_get_status(handle, address.as_ptr()); + assert!(!result.error.is_null()); + assert_eq!((*result.error).code, DashSDKErrorCode::InvalidParameter); + let message = CStr::from_ptr((*result.error).message) + .to_string_lossy() + .into_owned(); + assert!( + message.contains("Invalid evonode address"), + "unexpected message: {message}" + ); + crate::dash_sdk_error_free(result.error); + destroy_mock_sdk_handle(handle); + } + } + + /// Every `EvoNodeStatus` field must reach the JSON — the wallet shows + /// the whole self-report, so a dropped field is a silently missing row. + #[test] + fn test_evonode_status_json_carries_every_field() { + let status = EvoNodeStatus { + version: Version { + software: Some(Software { + dapi: "1.2.3".to_string(), + drive: Some("4.5.6".to_string()), + tenderdash: Some("0.14.0-dev.1".to_string()), + }), + protocol: Some(Protocol { + tenderdash: Some(TenderdashProtocol { p2p: 9, block: 12 }), + drive: Some(DriveProtocol { + latest: 7, + current: 6, + next_epoch: 7, + }), + }), + }, + node: Node { + id: vec![0xAA; 20], + pro_tx_hash: Some(vec![0xBB; 32]), + }, + chain: Chain { + catching_up: true, + latest_block_hash: vec![0x11; 32], + latest_app_hash: vec![0x22; 32], + earliest_block_hash: vec![0x33; 32], + earliest_app_hash: vec![0x44; 32], + latest_block_height: 5000, + earliest_block_height: 10, + max_peer_block_height: 5001, + core_chain_locked_height: Some(750), + }, + network: Network { + chain_id: "dash-mainnet".to_string(), + peers_count: 50, + listening: true, + }, + state_sync: StateSync { + total_synced_time: 7200, + remaining_time: 60, + total_snapshots: 3, + chunk_process_avg_time: 25, + snapshot_height: 4500, + snapshot_chunks_count: 200, + backfilled_blocks: 1000, + backfill_blocks_total: 2000, + }, + time: Time { + local: 1_700_000_000_000, + block: Some(1_699_999_900_000), + genesis: Some(1_690_000_000_000), + epoch: Some(42), + }, + }; + + let json = evonode_status_json(&status); + + assert_eq!(json["version"]["software"]["dapi"], "1.2.3"); + assert_eq!(json["version"]["software"]["drive"], "4.5.6"); + assert_eq!(json["version"]["software"]["tenderdash"], "0.14.0-dev.1"); + assert_eq!(json["version"]["protocol"]["tenderdash"]["p2p"], 9); + assert_eq!(json["version"]["protocol"]["tenderdash"]["block"], 12); + assert_eq!(json["version"]["protocol"]["drive"]["latest"], 7); + assert_eq!(json["version"]["protocol"]["drive"]["current"], 6); + assert_eq!(json["version"]["protocol"]["drive"]["nextEpoch"], 7); + + assert_eq!(json["node"]["id"], "aa".repeat(20)); + assert_eq!(json["node"]["proTxHash"], "bb".repeat(32)); + + assert_eq!(json["chain"]["catchingUp"], true); + assert_eq!(json["chain"]["latestBlockHash"], "11".repeat(32)); + assert_eq!(json["chain"]["latestAppHash"], "22".repeat(32)); + assert_eq!(json["chain"]["earliestBlockHash"], "33".repeat(32)); + assert_eq!(json["chain"]["earliestAppHash"], "44".repeat(32)); + assert_eq!(json["chain"]["latestBlockHeight"], 5000); + assert_eq!(json["chain"]["earliestBlockHeight"], 10); + assert_eq!(json["chain"]["maxPeerBlockHeight"], 5001); + assert_eq!(json["chain"]["coreChainLockedHeight"], 750); + + assert_eq!(json["network"]["chainId"], "dash-mainnet"); + assert_eq!(json["network"]["peersCount"], 50); + assert_eq!(json["network"]["listening"], true); + + assert_eq!(json["stateSync"]["totalSyncedTime"], 7200); + assert_eq!(json["stateSync"]["remainingTime"], 60); + assert_eq!(json["stateSync"]["totalSnapshots"], 3); + assert_eq!(json["stateSync"]["chunkProcessAvgTime"], 25); + assert_eq!(json["stateSync"]["snapshotHeight"], 4500); + assert_eq!(json["stateSync"]["snapshotChunksCount"], 200); + assert_eq!(json["stateSync"]["backfilledBlocks"], 1000); + assert_eq!(json["stateSync"]["backfillBlocksTotal"], 2000); + + assert_eq!(json["time"]["local"], 1_700_000_000_000u64); + assert_eq!(json["time"]["block"], 1_699_999_900_000u64); + assert_eq!(json["time"]["genesis"], 1_690_000_000_000u64); + assert_eq!(json["time"]["epoch"], 42); + } + + /// Live: ask a real mainnet evonode through the FFI entry point. Needs + /// network access, so it is ignored by default: + /// `cargo test -p rs-sdk-ffi --lib evonode::queries::status -- --ignored --nocapture` + #[test] + #[ignore = "needs network access to a mainnet evonode"] + fn live_mainnet_evonode_status() { + use std::sync::Arc; + + // Same wiring as `dash_sdk_create_trusted` for mainnet: the builder + // needs a context provider even though getStatus never uses one. + let provider = Arc::new( + rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new( + dash_sdk::dpp::dashcore::Network::Mainnet, + None, + std::num::NonZeroUsize::new(100).unwrap(), + ) + .expect("trusted context provider"), + ); + let sdk = dash_sdk::SdkBuilder::new_mainnet() + .with_context_provider(provider) + .build() + .expect("mainnet sdk"); + // The SDK's own bootstrap list is built from the evo seeds, so its + // first entry is a reachable mainnet evonode DAPI address. + let address = sdk + .address_list() + .get_live_address() + .expect("a mainnet evonode address") + .uri() + .to_string(); + let wrapper = Box::new(crate::sdk::SDKWrapper { + sdk, + runtime: Arc::new(crate::runtime::BigStackRuntime::build_shared().expect("runtime")), + trusted_provider: None, + }); + let handle = Box::into_raw(wrapper) as *mut SDKHandle; + + unsafe { + let c_address = CString::new(address.clone()).unwrap(); + let result = dash_sdk_evonode_get_status(handle, c_address.as_ptr()); + if !result.error.is_null() { + let message = CStr::from_ptr((*result.error).message) + .to_string_lossy() + .into_owned(); + crate::dash_sdk_error_free(result.error); + panic!("getStatus from {address} failed: {message}"); + } + let json = CStr::from_ptr(result.data as *const c_char) + .to_str() + .expect("utf-8 json") + .to_string(); + println!("{address} -> {json}"); + let value: Value = serde_json::from_str(&json).expect("json object"); + assert!( + value["chain"]["latestBlockHeight"].as_u64().unwrap_or(0) > 0, + "a live node reports a positive height: {json}" + ); + assert!( + value["network"]["chainId"] + .as_str() + .is_some_and(|c| !c.is_empty()), + "a live node reports its chain id: {json}" + ); + crate::dash_sdk_string_free(result.data as *mut c_char); + crate::sdk::dash_sdk_destroy(handle); + } + } + + /// Optional fields the node omitted are `null`, never a fabricated zero + /// or empty string — the wallet renders them as "not reported". + #[test] + fn test_evonode_status_json_omitted_fields_are_null() { + let status = EvoNodeStatus::default(); + let json = evonode_status_json(&status); + + assert!(json["version"]["software"].is_null()); + assert!(json["version"]["protocol"].is_null()); + assert!(json["node"]["proTxHash"].is_null()); + assert!(json["chain"]["coreChainLockedHeight"].is_null()); + assert!(json["time"]["block"].is_null()); + assert!(json["time"]["genesis"].is_null()); + assert!(json["time"]["epoch"].is_null()); + // Required fields are still present. + assert_eq!(json["node"]["id"], ""); + assert_eq!(json["chain"]["latestBlockHeight"], 0); + assert_eq!(json["network"]["chainId"], ""); + assert_eq!(json["time"]["local"], 0); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/EvonodeStatusQuery.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/EvonodeStatusQuery.swift new file mode 100644 index 00000000000..850a27fb5a9 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/EvonodeStatusQuery.swift @@ -0,0 +1,45 @@ +import DashSDKFFI +import Foundation + +// Not part of the `@MainActor` query extension on purpose: this is a +// blocking FFI call that callers run off the main actor (like +// `Identities.getBalance`), so it must stay nonisolated. +extension SDK { + /// Ask ONE evonode for its DAPI `getStatus` self-report. + /// + /// Unlike the queries in `PlatformQueryExtensions` this does not go + /// through the SDK's address list: the request is sent to `address` only + /// (`https://:`, e.g. `PlatformMasternode.platformDAPIAddress`), + /// with a single retry and no failover to another node — an unreachable + /// node surfaces as `SDKError.networkError` / `.timeout`. The response is + /// unproved by nature (the node describing itself); see `EvonodeStatus`. + /// + /// Blocking FFI call — run it off the main actor. + public func getEvonodeStatus(address: String) throws -> EvonodeStatus { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + + let result = address.withCString { cAddress in + dash_sdk_evonode_get_status(handle, cAddress) + } + + if let error = result.error { + defer { dash_sdk_error_free(error) } + throw SDKError.fromDashSDKError(error.pointee) + } + guard let dataPtr = result.data else { + throw SDKError.notFound("No status returned") + } + + let cString = dataPtr.assumingMemoryBound(to: CChar.self) + let json = String(cString: cString) + dash_sdk_string_free(cString) + + do { + return try JSONDecoder().decode(EvonodeStatus.self, from: Data(json.utf8)) + } catch { + throw SDKError.serializationError("Failed to decode evonode status: \(error)") + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Models/EvonodeStatus.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Models/EvonodeStatus.swift new file mode 100644 index 00000000000..91f8eb27f50 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Models/EvonodeStatus.swift @@ -0,0 +1,235 @@ +import Foundation + +/// One evonode's DAPI `getStatus` self-report, as returned by +/// `SDK.getEvonodeStatus(address:)` — every field the node sent, typed. +/// +/// Mirrors the Rust `EvoNodeStatus` (drive-proof-verifier) one-to-one; the +/// JSON wire shape is produced by `dash_sdk_evonode_get_status`. Optional +/// protobuf fields the node omitted decode as `nil` — callers should say +/// "not reported" rather than show a zero. The raw `Time` values are passed +/// through as sent; use `Time.localDate` / `blockDate` / `genesisDate` for +/// unit-correct dates (see `Time`). +/// +/// The report is unproved by nature — it is the node describing itself — so +/// treat it as diagnostics, not as chain state. +public struct EvonodeStatus: Codable, Equatable, Sendable { + public let version: Version + public let node: Node + public let chain: Chain + public let network: Network + public let stateSync: StateSync + public let time: Time + + /// Software and protocol versions the node runs. + public struct Version: Codable, Equatable, Sendable { + public let software: SoftwareVersions? + public let `protocol`: ProtocolVersions? + + public init(software: SoftwareVersions?, protocol: ProtocolVersions?) { + self.software = software + self.protocol = `protocol` + } + } + + /// Software component versions (semver strings). + public struct SoftwareVersions: Codable, Equatable, Sendable { + public let dapi: String + public let drive: String? + public let tenderdash: String? + + public init(dapi: String, drive: String?, tenderdash: String?) { + self.dapi = dapi + self.drive = drive + self.tenderdash = tenderdash + } + } + + /// Protocol-level versions. + public struct ProtocolVersions: Codable, Equatable, Sendable { + public let tenderdash: TenderdashProtocol? + public let drive: DriveProtocol? + + public init(tenderdash: TenderdashProtocol?, drive: DriveProtocol?) { + self.tenderdash = tenderdash + self.drive = drive + } + } + + public struct TenderdashProtocol: Codable, Equatable, Sendable { + /// Tenderdash P2P protocol version. + public let p2p: UInt32 + /// Tenderdash block protocol version. + public let block: UInt32 + + public init(p2p: UInt32, block: UInt32) { + self.p2p = p2p + self.block = block + } + } + + public struct DriveProtocol: Codable, Equatable, Sendable { + /// Latest protocol version the node supports. + public let latest: UInt32 + /// Protocol version the node currently runs. + public let current: UInt32 + /// Protocol version scheduled for the next epoch. + public let nextEpoch: UInt32 + + public init(latest: UInt32, current: UInt32, nextEpoch: UInt32) { + self.latest = latest + self.current = current + self.nextEpoch = nextEpoch + } + } + + /// Node identification. + public struct Node: Codable, Equatable, Sendable { + /// Tenderdash node id, hex. + public let id: String + /// proTxHash of the masternode, hex; `nil` for a full node. + public let proTxHash: String? + + public init(id: String, proTxHash: String?) { + self.id = id + self.proTxHash = proTxHash + } + } + + /// Layer-2 chain state as the node sees it. + public struct Chain: Codable, Equatable, Sendable { + /// Whether the node is still catching up with the network. + public let catchingUp: Bool + /// Hex hashes of the latest / earliest blocks the node holds. + public let latestBlockHash: String + public let latestAppHash: String + public let earliestBlockHash: String + public let earliestAppHash: String + public let latestBlockHeight: UInt64 + public let earliestBlockHeight: UInt64 + /// Highest block height among the node's connected peers. + public let maxPeerBlockHeight: UInt64 + /// Core height the chain is locked to, when reported. + public let coreChainLockedHeight: UInt32? + + public init( + catchingUp: Bool, + latestBlockHash: String, + latestAppHash: String, + earliestBlockHash: String, + earliestAppHash: String, + latestBlockHeight: UInt64, + earliestBlockHeight: UInt64, + maxPeerBlockHeight: UInt64, + coreChainLockedHeight: UInt32? + ) { + self.catchingUp = catchingUp + self.latestBlockHash = latestBlockHash + self.latestAppHash = latestAppHash + self.earliestBlockHash = earliestBlockHash + self.earliestAppHash = earliestAppHash + self.latestBlockHeight = latestBlockHeight + self.earliestBlockHeight = earliestBlockHeight + self.maxPeerBlockHeight = maxPeerBlockHeight + self.coreChainLockedHeight = coreChainLockedHeight + } + } + + /// Node networking information. + public struct Network: Codable, Equatable, Sendable { + /// Identifier of the chain the node is a member of (e.g. `dash-testnet-51`). + public let chainId: String + /// Number of peers in the node's address book. + public let peersCount: UInt32 + /// Whether the node is listening for incoming connections. + public let listening: Bool + + public init(chainId: String, peersCount: UInt32, listening: Bool) { + self.chainId = chainId + self.peersCount = peersCount + self.listening = listening + } + } + + /// State-sync (snapshot) progress. + public struct StateSync: Codable, Equatable, Sendable { + public let totalSyncedTime: UInt64 + public let remainingTime: UInt64 + public let totalSnapshots: UInt32 + public let chunkProcessAvgTime: UInt64 + public let snapshotHeight: UInt64 + public let snapshotChunksCount: UInt64 + public let backfilledBlocks: UInt64 + public let backfillBlocksTotal: UInt64 + + public init( + totalSyncedTime: UInt64, + remainingTime: UInt64, + totalSnapshots: UInt32, + chunkProcessAvgTime: UInt64, + snapshotHeight: UInt64, + snapshotChunksCount: UInt64, + backfilledBlocks: UInt64, + backfillBlocksTotal: UInt64 + ) { + self.totalSyncedTime = totalSyncedTime + self.remainingTime = remainingTime + self.totalSnapshots = totalSnapshots + self.chunkProcessAvgTime = chunkProcessAvgTime + self.snapshotHeight = snapshotHeight + self.snapshotChunksCount = snapshotChunksCount + self.backfilledBlocks = backfilledBlocks + self.backfillBlocksTotal = backfillBlocksTotal + } + } + + /// Clocks as the node sees them, raw as sent. Units differ by field and + /// by DAPI implementation: `block` / `genesis` are Unix milliseconds + /// (Drive's `time_ms`; Drive sends `0` for an unknown genesis), while + /// `local` is Unix seconds from rs-dapi and milliseconds from the legacy + /// JS DAPI. The `*Date` accessors resolve that — prefer them. + public struct Time: Codable, Equatable, Sendable { + /// The node's local wall clock at response time (seconds or ms — see above). + public let local: UInt64 + /// Time of the latest block, ms. + public let block: UInt64? + /// Genesis time, ms; `0` when the node doesn't know it. + public let genesis: UInt64? + /// Current epoch index. + public let epoch: UInt32? + + public init(local: UInt64, block: UInt64?, genesis: UInt64?, epoch: UInt32?) { + self.local = local + self.block = block + self.genesis = genesis + self.epoch = epoch + } + + /// The node's wall clock, or `nil` when it sent `0`. + public var localDate: Date? { Self.date(fromUnix: local) } + /// Latest block time, or `nil` when absent / `0`. + public var blockDate: Date? { block.flatMap(Self.date(fromUnix:)) } + /// Genesis time, or `nil` when absent / `0` (unknown to the node). + public var genesisDate: Date? { genesis.flatMap(Self.date(fromUnix:)) } + + /// Unix seconds and Unix milliseconds never overlap in magnitude for + /// any date a node can report: seconds stay below 10^11 until the + /// year 5138, and milliseconds passed 10^11 in 1973. `0` is "not + /// reported", never the epoch. + static func date(fromUnix value: UInt64) -> Date? { + guard value > 0 else { return nil } + let seconds = value >= 100_000_000_000 + ? TimeInterval(value) / 1000 + : TimeInterval(value) + return Date(timeIntervalSince1970: seconds) + } + } + + public init(version: Version, node: Node, chain: Chain, network: Network, stateSync: StateSync, time: Time) { + self.version = version + self.node = node + self.chain = chain + self.network = network + self.stateSync = stateSync + self.time = time + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 03fd66de562..f9483027a6b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -30,6 +30,11 @@ public struct PlatformMasternode: Sendable { public let ownerKeyHash: Data? public let votingKeyHash: Data? public let serviceAddress: String? + /// Platform HTTP (DAPI gRPC) port from the latest ProRegTx / ProUpServTx + /// — evonodes only, `nil` for a regular masternode. With the + /// `serviceAddress` host this addresses the node's own DAPI; see + /// `platformDAPIAddress`. + public let platformHTTPPort: UInt16? /// Base58 owner / voting P2PKH addresses (Rust-encoded for the /// network) — the join key for a provider-key account's address rows. public let ownerAddress: String? @@ -65,6 +70,35 @@ public struct PlatformMasternode: Sendable { public let platformOwnershipChecked: Bool } +extension PlatformMasternode { + /// The node's own DAPI endpoint, `https://:` + /// — the same shape the SDK builds for its seed address list — or `nil` + /// when either half is unknown (regular masternode, no service address + /// seen, or a payload without platform fields). The Core P2P port in + /// `serviceAddress` is intentionally dropped: DAPI listens on the + /// platform HTTP port. + public var platformDAPIAddress: String? { + guard let platformHTTPPort, let host = serviceHost else { return nil } + // An IPv6 literal must be bracketed in a URI authority. + let authorityHost = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host + return "https://\(authorityHost):\(platformHTTPPort)" + } + + /// Host half of `serviceAddress` (`"1.2.3.4:9999"` → `"1.2.3.4"`, + /// `"[2001:db8::1]:9999"` → `"[2001:db8::1]"`, `"2001:db8::1:9999"` → + /// `"2001:db8::1"`), or `nil`. + public var serviceHost: String? { + guard let serviceAddress else { return nil } + if serviceAddress.hasPrefix("[") { + // Bracketed IPv6 literal — the host is everything through `]`. + guard let close = serviceAddress.firstIndex(of: "]") else { return nil } + return String(serviceAddress[...close]) + } + guard let colon = serviceAddress.lastIndex(of: ":") else { return serviceAddress } + return String(serviceAddress[.. PlatformMasternode { + PlatformMasternode( + proTxHash: Data(repeating: 1, count: 32), + hasRegistration: true, + registrationHeight: 1, + orderIndex: 0, + typeIndex: 1, + isEvonode: platformHTTPPort != nil, + revoked: false, + revocationReason: 0, + status: 0, + txCount: 1, + collateralTxid: nil, + collateralVout: 0, + ownerKeyHash: nil, + votingKeyHash: nil, + serviceAddress: serviceAddress, + platformHTTPPort: platformHTTPPort, + ownerAddress: nil, + votingAddress: nil, + operatorPublicKey: nil, + platformNodeId: nil, + payoutAddress: nil, + operatorPseudoAddress: nil, + platformNodeAddress: nil, + operatorInWallet: false, + operatorAccountType: 0, + operatorKeyIndex: 0, + platformInWallet: false, + platformAccountType: 0, + platformKeyIndex: 0, + platformOwnershipChecked: false) + } + + func testPlatformDAPIAddressDropsTheCorePort() { + XCTAssertEqual( + masternode(serviceAddress: "203.0.113.7:9999", platformHTTPPort: 443).platformDAPIAddress, + "https://203.0.113.7:443") + XCTAssertEqual( + masternode(serviceAddress: "203.0.113.7:19999", platformHTTPPort: 1443).platformDAPIAddress, + "https://203.0.113.7:1443") + } + + func testPlatformDAPIAddressBracketsIPv6() { + XCTAssertEqual( + masternode(serviceAddress: "[2001:db8::1]:9999", platformHTTPPort: 443).platformDAPIAddress, + "https://[2001:db8::1]:443") + // ProUpServTx addresses are formatted `ip:port` without brackets. + XCTAssertEqual( + masternode(serviceAddress: "2001:db8::1:9999", platformHTTPPort: 443).platformDAPIAddress, + "https://[2001:db8::1]:443") + } + + func testPlatformDAPIAddressIsNilWithoutEitherHalf() { + XCTAssertNil(masternode(serviceAddress: "203.0.113.7:9999", platformHTTPPort: nil).platformDAPIAddress, + "regular masternode: no platform port ⇒ no DAPI address") + XCTAssertNil(masternode(serviceAddress: nil, platformHTTPPort: 443).platformDAPIAddress, + "no service address seen ⇒ no host to build from") + } +}