From b23e7ecbf644a6d1c97434e178b84586d9eb7a0a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 28 Sep 2026 09:58:48 +0700 Subject: [PATCH 1/2] fix(platform-wallet)!: caller-confirmed service values and an output on every ProUpServTx The update-service (unban) flow now takes the service address as an explicit, caller-confirmed input instead of copying it from the synced masternode list: IP:port always, and for an evonode the platform node id, platform P2P port and platform HTTP port. The payload is built from that input alone. The confirmed type is checked against the registration transaction, which is already bound to the requested proTxHash, and values no payload can carry (a non-IPv4 or unspecified address, a zero port, an empty node id, clashing ports) are refused before any network work. A read-only suggestion accessor (library, FFI and Swift) returns what the list shows so hosts can prefill their form, including whether the list shows the node PoSe-banned. Nothing refuses on that status. A ProUpServTx always carries an output: the builder adds a zero-value data output, the same placeholder Dash Core's protx update_service adds, so a selection whose leftover is below the dust limit no longer yields a transaction with no outputs. The payload finalizer also refuses an output-less transaction before signing, which releases the reservation. BREAKING CHANGE: MasternodeUpdateServiceParams takes a ConfirmedMasternodeService in place of platform_p2p_port. The four update-service FFI entry points take service_address, platform_node_id, platform_p2p_port and platform_http_port in place of has_platform_p2p_port and platform_p2p_port. The Swift wrappers take a MasternodeServiceConfirmation in place of platformP2PPort. Co-Authored-By: Claude Opus 5.5 --- .../src/masternode_update_service.rs | 580 ++++++++-- .../rs-platform-wallet/src/masternode/mod.rs | 3 +- .../src/masternode/update_service.rs | 988 ++++++++++++++---- .../PlatformWalletManagerMasternodes.swift | 261 ++++- ...tformWalletManagerTrackedMasternodes.swift | 105 +- .../MasternodeServiceConfirmationTests.swift | 59 ++ 6 files changed, 1590 insertions(+), 406 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs index f716669be84..29d06006d53 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs @@ -1,10 +1,10 @@ //! FFI bindings for the masternode update-service (ProUpServTx / unban) //! action — `platform_wallet::masternode::update_service`. //! -//! Two entry points, mirroring the withdraw pair. Both fund the L1 fee from -//! `wallet_id`'s core funds (input signing goes through the host's mnemonic -//! resolver, like every wallet-key signing path); they differ only in where -//! the operator BLS key comes from: +//! Two entry points, mirroring the withdraw pair, each with a prepare-only +//! twin. All fund the L1 fee from `wallet_id`'s core funds (input signing +//! goes through the host's mnemonic resolver, like every wallet-key signing +//! path); the pairs differ only in where the operator BLS key comes from: //! //! - [`platform_wallet_manager_masternode_update_service`][]: wallet-owned //! masternodes — the operator key is derived from the wallet's @@ -15,20 +15,28 @@ //! hex or 32-byte base64), parsed and matched exactly like //! `platform_wallet_manager_masternode_verify_key`. //! -//! The action is revive-only: service values are copied from the live -//! masternode-list entry. `out_txid` (32 wire-order bytes) is written only -//! when the broadcast definitively succeeded; an ambiguous outcome returns +//! The service values are an explicit input the host has the user confirm: +//! `service_address` (`"a.b.c.d:port"`) always, and for an evonode +//! `platform_node_id` (20 bytes) with both platform ports. The payload is +//! built from that input alone. +//! [`platform_wallet_manager_masternode_update_service_suggestion`] reads +//! what the synced masternode list shows, as a hint to prefill the form. +//! +//! `out_txid` (32 wire-order bytes) is written only when the broadcast +//! definitively succeeded; an ambiguous outcome returns //! `ErrorTransactionBroadcastUnconfirmed` and the reserved inputs stay held -//! for the wallet's normal reconciliation — never retry the call on that +//! for the wallet's normal reconciliation. Never retry the call on that //! code. +use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::sync::Arc; use dashcore::hashes::Hash; use platform_wallet::masternode::{ - execute_masternode_update_service, parse_secret_for_role, prepare_masternode_update_service, - LocatorSecret, MasternodeKeyRole, MasternodeUpdateServiceParams, + execute_masternode_update_service, masternode_update_service_suggestion, parse_secret_for_role, + parse_service_address, prepare_masternode_update_service, ConfirmedMasternodeService, + LocatorSecret, MasternodeKeyRole, MasternodeServiceSuggestion, MasternodeUpdateServiceParams, }; use platform_wallet::{PlatformWallet, ProviderKeyKind}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; @@ -169,40 +177,52 @@ fn tracked_operator_secret( } } +/// Marshal the confirmed service values and the rest of the request. +/// `platform_node_id` selects the shape: null for a regular masternode +/// (whose platform ports must then be 0), 20 bytes for an evonode (whose +/// ports the library then requires). unsafe fn marshal_params( pro_tx_hash: *const u8, - has_platform_p2p_port: bool, + service_address: *const c_char, + platform_node_id: *const u8, platform_p2p_port: u16, + platform_http_port: u16, operator_payout_address: *const c_char, ) -> Result { + let service_text = CStr::from_ptr(service_address).to_str()?; + let service_address = + parse_service_address(service_text).map_err(PlatformWalletFFIResult::from)?; + let service = if platform_node_id.is_null() { + if platform_p2p_port != 0 || platform_http_port != 0 { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "platform ports were given without a platform node id; they apply only to an \ + evonode", + )); + } + ConfirmedMasternodeService::Regular { service_address } + } else { + ConfirmedMasternodeService::Evonode { + service_address, + platform_node_id: std::ptr::read(platform_node_id as *const [u8; 20]), + platform_p2p_port, + platform_http_port, + } + }; Ok(MasternodeUpdateServiceParams { pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), - platform_p2p_port: has_platform_p2p_port.then_some(platform_p2p_port), + service, operator_payout_address: optional_string(operator_payout_address)?, }) } -#[allow(clippy::too_many_arguments)] unsafe fn run_update_service( context: ResolvedContext, - pro_tx_hash: *const u8, + params: MasternodeUpdateServiceParams, operator_secret: Zeroizing<[u8; 32]>, - has_platform_p2p_port: bool, - platform_p2p_port: u16, - operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], ) -> PlatformWalletFFIResult { - let params = match marshal_params( - pro_tx_hash, - has_platform_p2p_port, - platform_p2p_port, - operator_payout_address, - ) { - Ok(params) => params, - Err(e) => return e, - }; - let ResolvedContext { wallet, spv, @@ -230,27 +250,13 @@ unsafe fn run_update_service( /// reservation — as a core signed-transaction handle the host later /// broadcasts (`core_wallet_broadcast_signed_transaction`), abandons /// (`core_wallet_abandon_signed_transaction`) or frees (which abandons). -#[allow(clippy::too_many_arguments)] unsafe fn run_prepare_update_service( context: ResolvedContext, - pro_tx_hash: *const u8, + params: MasternodeUpdateServiceParams, operator_secret: Zeroizing<[u8; 32]>, - has_platform_p2p_port: bool, - platform_p2p_port: u16, - operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_transaction_handle: *mut Handle, ) -> PlatformWalletFFIResult { - let params = match marshal_params( - pro_tx_hash, - has_platform_p2p_port, - platform_p2p_port, - operator_payout_address, - ) { - Ok(params) => params, - Err(e) => return e, - }; - let ResolvedContext { wallet, spv, @@ -276,29 +282,34 @@ unsafe fn run_prepare_update_service( PlatformWalletFFIResult::ok() } -/// Broadcast a ProUpServTx re-asserting a wallet-owned masternode's current -/// service values — which revives it if it is PoSe-banned — signed with the -/// wallet's operator key at `operator_key_index` (the index the masternode -/// record's `operator_key_index` join field reports). +/// Broadcast a ProUpServTx asserting a wallet-owned masternode's confirmed +/// service values, which also revives it if it is PoSe-banned, signed with +/// the wallet's operator key at `operator_key_index` (the index the +/// masternode record's `operator_key_index` join field reports). /// -/// - `wallet_id` / `pro_tx_hash` — 32 bytes each; `pro_tx_hash` in WIRE +/// - `wallet_id` / `pro_tx_hash`: 32 bytes each; `pro_tx_hash` in WIRE /// order, as the masternode list reports it. -/// - `platform_p2p_port` (honoured when `has_platform_p2p_port`) — required -/// for an evonode, forbidden otherwise; the masternode list does not -/// carry it. -/// - `operator_payout_address` — nullable. Must be null when the ProRegTx's +/// - `service_address`: required NUL-terminated `"a.b.c.d:port"`, the Core +/// P2P endpoint the user confirmed. The payload carries it, not the +/// masternode list's entry. +/// - `platform_node_id`: null for a regular masternode, or 20 bytes for an +/// evonode, which then also needs `platform_p2p_port` and +/// `platform_http_port`. A regular masternode passes 0 for both ports. +/// The shape must match the masternode's registration. +/// - `operator_payout_address`: nullable. Must be null when the ProRegTx's /// `operatorReward` is 0, and must be given when it is not (the payload /// REPLACES the payout script on-chain; an empty one would clear it). -/// - `out_txid` — 32 wire-order bytes, written on definitive success. +/// - `out_txid`: 32 wire-order bytes, written on definitive success. /// /// On `ErrorTransactionBroadcastUnconfirmed` the outcome is ambiguous: the -/// reserved inputs stay held and the wallet reconciles through sync — do +/// reserved inputs stay held and the wallet reconciles through sync. Do /// not retry. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `mnemonic_resolver_handle` -/// must come from `dash_sdk_mnemonic_resolver_create` and remain valid for -/// the duration of the call. +/// Pointer args must be valid for the stated sizes; `service_address` must +/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come +/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the +/// duration of the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( @@ -306,8 +317,10 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_index: u32, - has_platform_p2p_port: bool, + service_address: *const c_char, + platform_node_id: *const u8, platform_p2p_port: u16, + platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], @@ -318,8 +331,21 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( *out_txid = [0u8; 32]; check_ptr!(wallet_id); check_ptr!(pro_tx_hash); + check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); + // Marshal first so malformed input fails before the resolver is used. + let params = match marshal_params( + pro_tx_hash, + service_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, Err(e) => return e, @@ -334,18 +360,15 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( }; run_update_service( context, - pro_tx_hash, + params, operator_secret, - has_platform_p2p_port, - platform_p2p_port, - operator_payout_address, mnemonic_resolver_handle, out_txid, ) } -/// Broadcast a ProUpServTx re-asserting a masternode's current service -/// values — which revives it if it is PoSe-banned — signed with a +/// Broadcast a ProUpServTx asserting a masternode's confirmed service +/// values, which also revives it if it is PoSe-banned, signed with a /// host-supplied operator key (the tracked-masternode vault's key text: /// 64-char hex or 32-byte base64). The L1 fee is still funded from /// `wallet_id`'s core funds through the mnemonic resolver. @@ -357,10 +380,11 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( /// serialization) before any network work. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `operator_key_text` -/// must be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must -/// come from `dash_sdk_mnemonic_resolver_create` and remain valid for the -/// duration of the call. +/// Pointer args must be valid for the stated sizes; `operator_key_text` and +/// `service_address` must be NUL-terminated UTF-8 strings; +/// `mnemonic_resolver_handle` must come from +/// `dash_sdk_mnemonic_resolver_create` and remain valid for the duration of +/// the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_service( @@ -368,8 +392,10 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_text: *const c_char, - has_platform_p2p_port: bool, + service_address: *const c_char, + platform_node_id: *const u8, platform_p2p_port: u16, + platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], @@ -381,9 +407,21 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi check_ptr!(wallet_id); check_ptr!(pro_tx_hash); check_ptr!(operator_key_text); + check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); - let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(operator_key_text).to_str()); + let key_text = unwrap_result_or_return!(CStr::from_ptr(operator_key_text).to_str()); + let params = match marshal_params( + pro_tx_hash, + service_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, @@ -393,16 +431,7 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi Ok(secret) => secret, Err(e) => return e, }; - run_update_service( - context, - pro_tx_hash, - secret, - has_platform_p2p_port, - platform_p2p_port, - operator_payout_address, - mnemonic_resolver_handle, - out_txid, - ) + run_update_service(context, params, secret, mnemonic_resolver_handle, out_txid) } /// Prepare — but do NOT broadcast — the ProUpServTx that @@ -422,9 +451,10 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi /// the TTL backstop reclaims it. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `mnemonic_resolver_handle` -/// must come from `dash_sdk_mnemonic_resolver_create` and remain valid for -/// the duration of the call. +/// Pointer args must be valid for the stated sizes; `service_address` must +/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come +/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the +/// duration of the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_service( @@ -432,8 +462,10 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_index: u32, - has_platform_p2p_port: bool, + service_address: *const c_char, + platform_node_id: *const u8, platform_p2p_port: u16, + platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_transaction_handle: *mut Handle, @@ -444,8 +476,20 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi *out_transaction_handle = 0; check_ptr!(wallet_id); check_ptr!(pro_tx_hash); + check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); + let params = match marshal_params( + pro_tx_hash, + service_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, Err(e) => return e, @@ -460,11 +504,8 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi }; run_prepare_update_service( context, - pro_tx_hash, + params, operator_secret, - has_platform_p2p_port, - platform_p2p_port, - operator_payout_address, mnemonic_resolver_handle, out_transaction_handle, ) @@ -478,10 +519,11 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi /// those of [`platform_wallet_manager_masternode_prepare_update_service`][]. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `operator_key_text` must -/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come -/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the -/// duration of the call. +/// Pointer args must be valid for the stated sizes; `operator_key_text` and +/// `service_address` must be NUL-terminated UTF-8 strings; +/// `mnemonic_resolver_handle` must come from +/// `dash_sdk_mnemonic_resolver_create` and remain valid for the duration of +/// the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_update_service( @@ -489,8 +531,10 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_upda wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_text: *const c_char, - has_platform_p2p_port: bool, + service_address: *const c_char, + platform_node_id: *const u8, platform_p2p_port: u16, + platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_transaction_handle: *mut Handle, @@ -502,9 +546,21 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_upda check_ptr!(wallet_id); check_ptr!(pro_tx_hash); check_ptr!(operator_key_text); + check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); - let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(operator_key_text).to_str()); + let key_text = unwrap_result_or_return!(CStr::from_ptr(operator_key_text).to_str()); + let params = match marshal_params( + pro_tx_hash, + service_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, Err(e) => return e, @@ -515,28 +571,138 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_upda }; run_prepare_update_service( context, - pro_tx_hash, + params, secret, - has_platform_p2p_port, - platform_p2p_port, - operator_payout_address, mnemonic_resolver_handle, out_transaction_handle, ) } +/// What the synced masternode list shows for a masternode's service, for +/// the host to prefill its update-service form. Returned by +/// [`platform_wallet_manager_masternode_update_service_suggestion`]. +/// +/// A hint only: the host shows these values and has the user confirm or +/// correct them before passing them to an update-service call. The list does not carry the +/// platform P2P port, so an evonode's always has to be entered. +#[repr(C)] +pub struct MasternodeServiceSuggestionFFI { + /// `"ip:port"` of the Core P2P endpoint the list shows: a heap C string, + /// or null when the entry has no plain IP:port (Tor / I2P / domain-only). + /// Free with [`crate::platform_wallet_string_free`]. + pub service_address: *mut c_char, + /// The list shows a high-performance (evonode) entry. + pub is_evonode: bool, + /// Tenderdash node id, gated by `has_platform_node_id` (evonodes only). + pub platform_node_id: [u8; 20], + pub has_platform_node_id: bool, + /// Platform HTTP (DAPI) port, gated by `has_platform_http_port` + /// (evonodes only). + pub platform_http_port: u16, + pub has_platform_http_port: bool, + /// The list shows the masternode PoSe-banned. When it does not, the host + /// may warn that the node looks healthy; updating a healthy node's + /// service is legitimate, so nothing refuses on it. + pub pose_banned: bool, + /// The list shows v3 extended network info, which the update refuses (a + /// version-2 payload would replace the whole endpoint map). + pub has_extended_net_info: bool, +} + +impl MasternodeServiceSuggestionFFI { + fn empty() -> Self { + Self { + service_address: std::ptr::null_mut(), + is_evonode: false, + platform_node_id: [0u8; 20], + has_platform_node_id: false, + platform_http_port: 0, + has_platform_http_port: false, + pose_banned: false, + has_extended_net_info: false, + } + } + + fn from_suggestion(suggestion: &MasternodeServiceSuggestion) -> Self { + Self { + service_address: suggestion + .service_address + .and_then(|address| CString::new(address.to_string()).ok()) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()), + is_evonode: suggestion.is_evonode, + platform_node_id: suggestion.platform_node_id.unwrap_or([0u8; 20]), + has_platform_node_id: suggestion.platform_node_id.is_some(), + platform_http_port: suggestion.platform_http_port.unwrap_or(0), + has_platform_http_port: suggestion.platform_http_port.is_some(), + pose_banned: suggestion.pose_banned, + has_extended_net_info: suggestion.has_extended_net_info, + } + } +} + +/// Read what the synced masternode list shows for `pro_tx_hash` (32 WIRE +/// bytes) as a prefill suggestion for the update-service form. Read-only +/// and local. +/// +/// Errors: `NotFound` when the list has no such masternode; +/// `ErrorMasternodeListUnavailable` before the list has synced. On any +/// error `out_suggestion` is left empty (null `service_address`). +/// +/// # Safety +/// `pro_tx_hash` must point at 32 readable bytes; `out_suggestion` must be +/// writable. Free a non-null `service_address` with +/// [`crate::platform_wallet_string_free`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service_suggestion( + manager_handle: Handle, + pro_tx_hash: *const u8, + out_suggestion: *mut MasternodeServiceSuggestionFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_suggestion); + std::ptr::write(out_suggestion, MasternodeServiceSuggestionFFI::empty()); + check_ptr!(pro_tx_hash); + + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let Some(spv) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| manager.spv_arc()) + else { + return invalid_handle(); + }; + let suggestion = unwrap_result_or_return!(block_on_worker(async move { + masternode_update_service_suggestion(&spv, &target).await + })); + let Some(suggestion) = suggestion else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "no masternode with this proTxHash on the masternode list", + ); + }; + std::ptr::write( + out_suggestion, + MasternodeServiceSuggestionFFI::from_suggestion(&suggestion), + ); + PlatformWalletFFIResult::ok() +} + #[cfg(test)] mod tests { use super::*; use crate::platform_wallet_ffi_result_free; + use crate::platform_wallet_string_free; + + fn service(text: &str) -> CString { + CString::new(text).expect("no interior NUL") + } /// Unknown manager handles must come back as `ErrorInvalidHandle` with - /// the out-param still zeroed — mirroring the withdraw pair's contract. + /// the out-param still zeroed, mirroring the withdraw pair's contract. #[test] - fn unknown_handles_are_invalid_handles() { + fn should_report_unknown_manager_handles_as_invalid() { unsafe { let wallet_id = [0u8; 32]; let pro_tx_hash = [0u8; 32]; + let address = service("203.0.113.7:19999"); let mut txid = [0xAAu8; 32]; // A dangling-but-non-null resolver pointer is fine: the handle // lookup fails before the resolver is ever touched. @@ -547,7 +713,9 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), 0, - false, + address.as_ptr(), + std::ptr::null(), + 0, 0, std::ptr::null(), resolver, @@ -559,13 +727,15 @@ mod tests { platform_wallet_ffi_result_free(&mut result); let mut txid = [0xAAu8; 32]; - let key = std::ffi::CString::new("00").unwrap(); + let key = CString::new("00").unwrap(); let result = platform_wallet_manager_tracked_masternode_update_service( Handle::MAX, wallet_id.as_ptr(), pro_tx_hash.as_ptr(), key.as_ptr(), - false, + address.as_ptr(), + std::ptr::null(), + 0, 0, std::ptr::null(), resolver, @@ -582,10 +752,11 @@ mod tests { /// is an invalid-handle error and the out-param is left at the null /// handle, so a host can never broadcast a stale handle after a failure. #[test] - fn prepare_unknown_handles_are_invalid_handles() { + fn should_report_unknown_manager_handles_as_invalid_when_preparing() { unsafe { let wallet_id = [0u8; 32]; let pro_tx_hash = [0u8; 32]; + let address = service("203.0.113.7:19999"); let resolver = std::ptr::dangling_mut::(); let mut transaction_handle: Handle = 7; @@ -594,7 +765,9 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), 0, - false, + address.as_ptr(), + std::ptr::null(), + 0, 0, std::ptr::null(), resolver, @@ -606,13 +779,15 @@ mod tests { platform_wallet_ffi_result_free(&mut result); let mut transaction_handle: Handle = 7; - let key = std::ffi::CString::new("00").unwrap(); + let key = CString::new("00").unwrap(); let result = platform_wallet_manager_tracked_masternode_prepare_update_service( Handle::MAX, wallet_id.as_ptr(), pro_tx_hash.as_ptr(), key.as_ptr(), - false, + address.as_ptr(), + std::ptr::null(), + 0, 0, std::ptr::null(), resolver, @@ -625,13 +800,15 @@ mod tests { } } - /// Null required pointers are rejected before anything else runs — - /// and a valid `out_txid` is still zeroed first, per its contract. + /// Null required pointers are rejected before anything else runs, and + /// a valid `out_txid` is still zeroed first, per its contract. The + /// service address is required. #[test] - fn null_pointers_are_rejected() { + fn should_reject_null_pointers() { unsafe { let wallet_id = [0u8; 32]; let pro_tx_hash = [0u8; 32]; + let address = service("203.0.113.7:19999"); let mut txid = [0xAAu8; 32]; let result = platform_wallet_manager_masternode_update_service( @@ -639,7 +816,9 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), 0, - false, + address.as_ptr(), + std::ptr::null(), + 0, 0, std::ptr::null(), std::ptr::null_mut(), @@ -659,7 +838,9 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), std::ptr::null(), - false, + address.as_ptr(), + std::ptr::null(), + 0, 0, std::ptr::null(), std::ptr::dangling_mut::(), @@ -672,6 +853,183 @@ mod tests { ); let mut result = result; platform_wallet_ffi_result_free(&mut result); + + let mut transaction_handle: Handle = 7; + let result = platform_wallet_manager_masternode_prepare_update_service( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + std::ptr::null(), + std::ptr::null(), + 0, + 0, + std::ptr::null(), + std::ptr::dangling_mut::(), + &mut transaction_handle, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_eq!(transaction_handle, 0, "the service address is required"); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } + } + + /// Malformed service input fails as an invalid parameter before the + /// manager handle, the wallet or the resolver is touched. + #[test] + fn should_refuse_malformed_service_input_before_resolving_anything() { + unsafe { + let wallet_id = [0u8; 32]; + let pro_tx_hash = [0u8; 32]; + let resolver = std::ptr::dangling_mut::(); + let node_id = [0x5Au8; 20]; + let cases: [(&str, *const u8, u16, u16); 3] = [ + ("203.0.113.7", std::ptr::null(), 0, 0), + ("203.0.113.7:19999", std::ptr::null(), 36656, 0), + ("not an address", node_id.as_ptr(), 36656, 1443), + ]; + for (text, node_id, p2p, http) in cases { + let address = service(text); + let mut txid = [0xAAu8; 32]; + let result = platform_wallet_manager_masternode_update_service( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + address.as_ptr(), + node_id, + p2p, + http, + std::ptr::null(), + resolver, + &mut txid, + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "{text} with p2p {p2p} http {http}" + ); + assert_eq!(txid, [0u8; 32]); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } } } + + /// A null node id is a regular masternode; 20 bytes are an evonode with + /// the given platform ports. + #[test] + fn should_marshal_the_service_shape_from_the_platform_node_id() { + unsafe { + let pro_tx_hash = [0x11u8; 32]; + let address = service("203.0.113.7:19999"); + + let regular = marshal_params( + pro_tx_hash.as_ptr(), + address.as_ptr(), + std::ptr::null(), + 0, + 0, + std::ptr::null(), + ) + .unwrap_or_else(|_| panic!("a regular service marshals")); + assert_eq!(regular.pro_tx_hash, pro_tx_hash); + assert_eq!( + regular.service, + ConfirmedMasternodeService::Regular { + service_address: "203.0.113.7:19999".parse().unwrap(), + } + ); + assert_eq!(regular.operator_payout_address, None); + + let node_id = [0x5Au8; 20]; + let evonode = marshal_params( + pro_tx_hash.as_ptr(), + address.as_ptr(), + node_id.as_ptr(), + 36656, + 1443, + std::ptr::null(), + ) + .unwrap_or_else(|_| panic!("an evonode service marshals")); + assert_eq!( + evonode.service, + ConfirmedMasternodeService::Evonode { + service_address: "203.0.113.7:19999".parse().unwrap(), + platform_node_id: node_id, + platform_p2p_port: 36656, + platform_http_port: 1443, + } + ); + } + } + + #[test] + fn should_marshal_the_suggestion_fields() { + let suggestion = MasternodeServiceSuggestion { + service_address: Some("1.2.3.4:9999".parse().unwrap()), + is_evonode: true, + platform_node_id: Some([4u8; 20]), + platform_http_port: Some(443), + pose_banned: true, + has_extended_net_info: false, + }; + let ffi = MasternodeServiceSuggestionFFI::from_suggestion(&suggestion); + assert_eq!( + unsafe { CStr::from_ptr(ffi.service_address) } + .to_str() + .unwrap(), + "1.2.3.4:9999" + ); + assert!(ffi.is_evonode); + assert!(ffi.has_platform_node_id); + assert_eq!(ffi.platform_node_id, [4u8; 20]); + assert!(ffi.has_platform_http_port); + assert_eq!(ffi.platform_http_port, 443); + assert!(ffi.pose_banned); + assert!(!ffi.has_extended_net_info); + unsafe { platform_wallet_string_free(ffi.service_address) }; + + let without_address = + MasternodeServiceSuggestionFFI::from_suggestion(&MasternodeServiceSuggestion { + service_address: None, + is_evonode: false, + platform_node_id: None, + platform_http_port: None, + pose_banned: false, + has_extended_net_info: false, + }); + assert!(without_address.service_address.is_null()); + assert!(!without_address.has_platform_node_id); + assert!(!without_address.has_platform_http_port); + } + + #[test] + fn should_report_an_unknown_manager_handle_when_suggesting() { + let pro_tx_hash = [0u8; 32]; + let mut out = MasternodeServiceSuggestionFFI::empty(); + out.pose_banned = true; + let mut result = unsafe { + platform_wallet_manager_masternode_update_service_suggestion( + Handle::MAX, + pro_tx_hash.as_ptr(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(out.service_address.is_null()); + assert!(!out.pose_banned, "the out-param is reset on every path"); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + + let mut result = unsafe { + platform_wallet_manager_masternode_update_service_suggestion( + Handle::MAX, + std::ptr::null(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } } diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index 6c86b526105..ea52bd291e1 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -31,7 +31,8 @@ pub use tracked::{ PlatformKeySnapshot, RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot, }; pub use update_service::{ - execute_masternode_update_service, prepare_masternode_update_service, + execute_masternode_update_service, masternode_update_service_suggestion, parse_service_address, + prepare_masternode_update_service, ConfirmedMasternodeService, MasternodeServiceSuggestion, MasternodeUpdateServiceParams, }; diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 90a66fda515..6a9f9ce98b6 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -1,19 +1,22 @@ //! ProUpServTx (provider update service) orchestration. //! -//! Revives a PoSe-banned masternode or evonode by re-asserting its current -//! service values in a ProUpServTx signed with the operator BLS key. The -//! payload commits to the funding inputs (`inputs_hash`) and each funding -//! input's ECDSA sighash covers the finished payload, so the build order is -//! fixed: select and reserve inputs → write `inputs_hash` → BLS-sign the -//! payload → ECDSA-sign the inputs → broadcast. key-wallet's +//! Asserts a masternode's or evonode's service values in a ProUpServTx signed +//! with the operator BLS key, which also revives the node if it is +//! PoSe-banned. The payload commits to the funding inputs (`inputs_hash`) and +//! each funding input's ECDSA sighash covers the finished payload, so the +//! build order is fixed: select and reserve inputs → write `inputs_hash` → +//! BLS-sign the payload → ECDSA-sign the inputs → broadcast. key-wallet's //! `TransactionBuilder::set_payload_finalizer` is the seam that makes steps //! two and three possible between selection and input signing. //! -//! This is deliberately revive-only: every payload field except the payout -//! script is copied verbatim from the live masternode-list entry, and the -//! payout script follows a hard rule (see -//! [`resolve_operator_payout_script`]) so an unban can never silently clear -//! an operator payout on-chain. +//! The service values are an explicit input the caller confirms +//! ([`ConfirmedMasternodeService`]), and the payload is built from that input +//! alone. [`masternode_update_service_suggestion`] reads what the synced +//! masternode list shows so a host can prefill its form; that is a hint the +//! user confirms, never a payload source. The confirmed masternode type is +//! checked against the registration transaction, and the payout script +//! follows a hard rule (see [`resolve_operator_payout_script`]) so an update +//! can never silently clear an operator payout on-chain. use dashcore::blockdata::script::ScriptBuf; use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; @@ -26,7 +29,7 @@ use dashcore::blsful::{Bls12381G2Impl, SecretKey as BlsSecretKey, SignatureSchem use dashcore::hash_types::InputsHash; use dashcore::hashes::Hash; use dashcore::platform_node_id::PlatformNodeId; -use dashcore::{Address as DashAddress, Network, Txid}; +use dashcore::{Address as DashAddress, Network, Transaction, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, TransactionSigner, }; @@ -41,18 +44,16 @@ use crate::spv::SpvRuntime; use crate::wallet::core::{CoreWallet, SignedCoreTransaction, SEND_FUNDING_SOURCES}; use crate::wallet::platform_wallet::PlatformWallet; -/// What an update-service (unban) request lets the caller choose. Everything -/// else — service address, masternode type, platform node id and HTTP port — -/// is copied from the live masternode-list entry. +/// What an update-service request asserts and chooses. Everything the +/// payload carries comes from here; nothing is copied from the synced +/// masternode list. #[derive(Debug, Clone)] pub struct MasternodeUpdateServiceParams { /// ProRegTx hash of the masternode to update, in WIRE order (the same /// order `MasternodeListSummary::pro_tx_hash` uses). pub pro_tx_hash: [u8; 32], - /// Platform P2P port for an evonode payload. The masternode list does - /// not carry it, so the caller must supply it for an evonode; it must be - /// `None` for a regular masternode. - pub platform_p2p_port: Option, + /// The service values the payload asserts, as the caller confirmed them. + pub service: ConfirmedMasternodeService, /// Operator payout address. Consensus REPLACES the current operator /// payout script with this payload's, and an empty script clears it — /// see [`resolve_operator_payout_script`] for the rule that keeps that @@ -60,10 +61,136 @@ pub struct MasternodeUpdateServiceParams { pub operator_payout_address: Option, } +/// The service values a ProUpServTx asserts, as the caller confirmed them. +/// +/// Hosts may prefill these from [`masternode_update_service_suggestion`], +/// but the operator is the one asserting them, so every value must be shown +/// to the user and confirmed (or corrected) before it is passed in. The +/// variant must match how the masternode was registered; the update refuses +/// a mismatch against the registration transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmedMasternodeService { + /// A regular masternode: the Core P2P endpoint only. + Regular { + /// Core P2P endpoint. The version-2 payload carries an IPv4 address. + service_address: SocketAddr, + }, + /// An evonode: the Core P2P endpoint plus the platform fields the + /// payload serializes for the high-performance type. + Evonode { + /// Core P2P endpoint. The version-2 payload carries an IPv4 address. + service_address: SocketAddr, + /// Tenderdash node id (`SHA256(ed25519 pk)[..20]`). + platform_node_id: [u8; 20], + /// Tenderdash P2P port. + platform_p2p_port: u16, + /// Platform HTTP (DAPI) port. + platform_http_port: u16, + }, +} + +impl ConfirmedMasternodeService { + /// The Core P2P endpoint, whichever the type. + pub fn service_address(&self) -> SocketAddr { + match self { + Self::Regular { service_address } + | Self::Evonode { + service_address, .. + } => *service_address, + } + } + + /// Whether this is the evonode (high-performance) shape. + pub fn is_evonode(&self) -> bool { + matches!(self, Self::Evonode { .. }) + } +} + +/// What the synced masternode list currently shows for a masternode's +/// service, for a host to prefill its update-service form. +/// +/// A hint only. The host must show these values and have the user confirm +/// or correct them; the confirmed values, not these, become the +/// [`ConfirmedMasternodeService`] the payload is built from. The list does +/// not carry the platform P2P port, so an evonode's always has to be +/// entered. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MasternodeServiceSuggestion { + /// Primary Core P2P endpoint. `None` for Tor / I2P / CJDNS / + /// domain-only entries, which have no IP:port form. + pub service_address: Option, + /// The list shows a high-performance (evonode) entry. + pub is_evonode: bool, + /// Tenderdash node id, evonodes only. + pub platform_node_id: Option<[u8; 20]>, + /// Platform HTTP (DAPI) port, evonodes only. + pub platform_http_port: Option, + /// The list shows the masternode PoSe-banned. When it does not, a host + /// may say so before the user sends: updating a healthy node's service + /// is legitimate, so nothing refuses on it. + pub pose_banned: bool, + /// The list shows v3 extended network info. The update refuses such an + /// entry, since a version-2 payload would replace its whole endpoint map + /// with one address, so a host can explain that before the user fills + /// in the form. + pub has_extended_net_info: bool, +} + +impl MasternodeServiceSuggestion { + /// The suggestion for one list entry. + pub fn from_summary(entry: &MasternodeListSummary) -> Self { + Self { + service_address: entry.service_address, + is_evonode: entry.is_evonode, + platform_node_id: entry.platform_node_id, + platform_http_port: entry.platform_http_port, + pose_banned: !entry.is_valid, + has_extended_net_info: entry.has_extended_net_info, + } + } +} + +/// What the synced masternode list shows for `pro_tx_hash` (wire order), as +/// a [`MasternodeServiceSuggestion`] a host prefills its form with and the +/// user then confirms. `None` when the list has no such masternode; errors +/// with [`PlatformWalletError::MasternodeListUnavailable`] before the list +/// has synced. Read-only and local. +pub async fn masternode_update_service_suggestion( + spv: &SpvRuntime, + pro_tx_hash: &[u8; 32], +) -> Result, PlatformWalletError> { + let summaries = spv + .masternode_list_summaries() + .await + .ok_or(PlatformWalletError::MasternodeListUnavailable)?; + Ok(summaries + .iter() + .find(|entry| &entry.pro_tx_hash == pro_tx_hash) + .map(MasternodeServiceSuggestion::from_summary)) +} + +/// Parse a host-entered `"a.b.c.d:port"` service address. The port is +/// required: the Core P2P port differs between mainnet and the test +/// networks, so it is never guessed. +pub fn parse_service_address(text: &str) -> Result { + let text = text.trim(); + text.parse::().map_err(|_| { + if text.parse::().is_ok() { + PlatformWalletError::InvalidParameter(format!( + "the service address {text} needs a port, as IP:port" + )) + } else { + PlatformWalletError::InvalidParameter(format!( + "{text:?} is not an IP:port service address" + )) + } + }) +} + /// Build, operator-BLS-sign, fund, input-sign, and broadcast a ProUpServTx -/// that re-asserts `pro_tx_hash`'s current service values — the transaction -/// Core produces for `protx update_service` — which revives the masternode -/// if it is PoSe-banned. +/// asserting `params.service` for `params.pro_tx_hash` (the transaction +/// Core produces for `protx update_service`), which also revives the +/// masternode if it is PoSe-banned. /// /// `operator_secret` is the operator's BLS12-381 secret scalar in big-endian /// bytes (the same convention as [`bls_public_keys`]); it is verified against @@ -99,6 +226,10 @@ pub async fn execute_masternode_update_service( wallet: &PlatformWallet, spv: &SpvRuntime, @@ -106,6 +237,8 @@ pub async fn prepare_masternode_update_service, signer: &S, ) -> Result { + validate_confirmed_service(¶ms.service)?; + let summaries = spv .masternode_list_summaries() .await @@ -119,29 +252,59 @@ pub async fn prepare_masternode_update_service Result<(), PlatformWalletError> { + verify_operator_secret(&entry.operator_public_key, operator_secret)?; + if entry.has_extended_net_info { + return Err(PlatformWalletError::InvalidParameter( + "this masternode advertises v3 extended network info; a version-2 update-service \ + payload would replace its whole endpoint map with a single address, so it cannot \ + be updated from this wallet yet" + .to_string(), + )); + } + Ok(()) +} - build_sign_update_service(wallet.core(), placeholder, operator_secret, signer).await +/// What the ProRegTx fixed for the masternode's lifetime, read from the +/// fetched registration transaction once it is bound to the requested +/// proTxHash. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RegistrationTerms { + /// `operatorReward` in basis points. + pub(crate) operator_reward: u16, + /// Regular or evonode. No transaction changes it after registration, + /// and a ProUpServTx must carry the same type. + pub(crate) masternode_type: ProviderMasternodeType, } -/// The `operatorReward` (basis points) the masternode was registered with, -/// read from its ProRegTx via DAPI Core. Fails closed when the transaction -/// cannot be fetched — the payout rule cannot be applied without it. -async fn fetch_operator_reward( +/// The [`RegistrationTerms`] of `pro_tx_hash`, from its ProRegTx fetched via +/// DAPI Core. Fails closed when the transaction cannot be fetched: neither +/// the payout rule nor the type check can be applied without it. +async fn fetch_registration_terms( wallet: &PlatformWallet, pro_tx_hash: &[u8; 32], -) -> Result { +) -> Result { let display = display_hex(pro_tx_hash); let fetched = wallet .sdk() @@ -158,20 +321,19 @@ async fn fetch_operator_reward( operator reward" )) })?; - operator_reward_from_registration(pro_tx_hash, &fetched.transaction) + registration_terms(pro_tx_hash, &fetched.transaction) } -/// Read `operatorReward` out of a fetched registration transaction — +/// Read the [`RegistrationTerms`] out of a fetched registration transaction, /// binding the response to the request first: DAPI's get-transaction reply /// is not authenticated, so the decoded transaction must hash to the -/// SPV-authenticated proTxHash before its payload is trusted. Without this -/// check a faulty or malicious endpoint could answer with an unrelated -/// zero-reward ProRegTx and steer [`resolve_operator_payout_script`] into -/// clearing a real operator payout. -pub(crate) fn operator_reward_from_registration( +/// requested proTxHash before its payload is trusted. Without this check a +/// reply carrying an unrelated zero-reward ProRegTx would steer +/// [`resolve_operator_payout_script`] into clearing a real operator payout. +pub(crate) fn registration_terms( pro_tx_hash: &[u8; 32], - transaction: &dashcore::Transaction, -) -> Result { + transaction: &Transaction, +) -> Result { let expected = Txid::from_byte_array(*pro_tx_hash); let actual = transaction.txid(); if actual != expected { @@ -182,7 +344,10 @@ pub(crate) fn operator_reward_from_registration( } match &transaction.special_transaction_payload { Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => { - Ok(registration.operator_reward) + Ok(RegistrationTerms { + operator_reward: registration.operator_reward, + masternode_type: registration.masternode_type, + }) } _ => Err(PlatformWalletError::InvalidParameter(format!( "transaction {expected} is not a provider registration transaction" @@ -255,80 +420,143 @@ pub(crate) fn verify_operator_secret( Ok(()) } -/// The placeholder payload for the builder: every field final except the two +/// Refuse confirmed service values no ProUpServTx can carry, before any +/// network work. Core validates the rest (routability, each network's +/// required ports, uniqueness across the list) when the transaction +/// arrives; these are the checks a mistyped value most often fails, and +/// catching them here keeps a transaction the network would refuse from +/// being built at all. +pub(crate) fn validate_confirmed_service( + service: &ConfirmedMasternodeService, +) -> Result<(), PlatformWalletError> { + let address = service.service_address(); + let ipv4 = match address.ip() { + IpAddr::V4(v4) => Some(v4), + IpAddr::V6(v6) => v6.to_ipv4_mapped(), + }; + let Some(ipv4) = ipv4 else { + return Err(PlatformWalletError::InvalidParameter(format!( + "the service address {address} is not IPv4; a version-2 update-service payload \ + carries an IPv4 address" + ))); + }; + if ipv4.is_unspecified() || address.port() == 0 { + return Err(PlatformWalletError::InvalidParameter(format!( + "the service address {address} needs a real IP and port" + ))); + } + if let ConfirmedMasternodeService::Evonode { + platform_node_id, + platform_p2p_port, + platform_http_port, + .. + } = service + { + if platform_node_id == &[0u8; 20] { + return Err(PlatformWalletError::InvalidParameter( + "an evonode payload needs the platform node id".to_string(), + )); + } + if *platform_p2p_port == 0 || *platform_http_port == 0 { + return Err(PlatformWalletError::InvalidParameter( + "an evonode payload needs both the platform P2P port and the platform HTTP port" + .to_string(), + )); + } + if platform_p2p_port == platform_http_port + || *platform_p2p_port == address.port() + || *platform_http_port == address.port() + { + return Err(PlatformWalletError::InvalidParameter( + "the Core P2P port, platform P2P port and platform HTTP port must all differ" + .to_string(), + )); + } + } + Ok(()) +} + +/// Refuse a confirmed service whose type differs from the registration's. +/// The type is fixed at registration and a ProUpServTx must repeat it, so +/// the registration transaction (bound to the proTxHash) decides. +pub(crate) fn check_registered_type( + registered: ProviderMasternodeType, + service: &ConfirmedMasternodeService, +) -> Result<(), PlatformWalletError> { + match (registered, service.is_evonode()) { + (ProviderMasternodeType::Regular, false) + | (ProviderMasternodeType::HighPerformance, true) => Ok(()), + (ProviderMasternodeType::HighPerformance, false) => { + Err(PlatformWalletError::InvalidParameter( + "this masternode is registered as an evonode; confirm its platform node id, \ + platform P2P port and platform HTTP port" + .to_string(), + )) + } + (ProviderMasternodeType::Regular, true) => Err(PlatformWalletError::InvalidParameter( + "this masternode is registered as a regular masternode, so it has no platform \ + fields to update" + .to_string(), + )), + } +} + +/// The placeholder payload for `params`, checked against the registration: +/// the confirmed service values, the registered type, and the payout script +/// the payout rule resolves. Nothing here reads the masternode list. +pub(crate) fn update_service_placeholder( + registration: &RegistrationTerms, + params: &MasternodeUpdateServiceParams, + network: Network, +) -> Result { + check_registered_type(registration.masternode_type, ¶ms.service)?; + let script_payout = resolve_operator_payout_script( + registration.operator_reward, + params.operator_payout_address.as_deref(), + network, + )?; + prepare_update_service_placeholder(params.pro_tx_hash, ¶ms.service, script_payout) +} + +/// The placeholder payload for the builder, built from the confirmed +/// service values alone: every field final except the two /// selection-dependent ones (`inputs_hash`, `payload_sig`), which are zeroed /// and filled by the payload finalizer after input selection. Always version /// 2 (BasicBLS) — every current network is past the v19 hard fork. pub(crate) fn prepare_update_service_placeholder( - entry: &MasternodeListSummary, - platform_p2p_port: Option, + pro_tx_hash: [u8; 32], + service: &ConfirmedMasternodeService, script_payout: ScriptBuf, ) -> Result { - // A v3 extended entry advertises an endpoint map the version-2 payload - // cannot express: Core would replace the whole map with the single - // address below, downgrading the entry and discarding live endpoints. - // Refuse until a v3 ProUpServTx payload exists to re-assert it. - if entry.has_extended_net_info { - return Err(PlatformWalletError::InvalidParameter( - "this masternode advertises v3 extended network info; a version-2 update-service \ - payload would replace its whole endpoint map with a single address, so it cannot \ - be re-asserted from this wallet yet" - .to_string(), - )); - } - let service = entry.service_address.ok_or_else(|| { - PlatformWalletError::InvalidParameter( - "the masternode's service address is not a plain IP:port entry, so it cannot be \ - re-asserted from the masternode list" - .to_string(), - ) - })?; - let (ip_address, port) = service_payload_fields(service); + validate_confirmed_service(service)?; + let (ip_address, port) = service_payload_fields(service.service_address()); // The platform triplet is serialized only when mn_type is HighPerformance, // so the evonode/regular split must be explicit here — a missing mn_type // would silently drop the platform fields on the wire. - let (mn_type, platform_node_id, platform_p2p_port, platform_http_port) = if entry.is_evonode { - let node_id = entry.platform_node_id.ok_or_else(|| { - PlatformWalletError::InvalidParameter( - "the masternode list entry is an evonode without a platform node id".to_string(), - ) - })?; - let http_port = entry.platform_http_port.ok_or_else(|| { - PlatformWalletError::InvalidParameter( - "the masternode list entry is an evonode without a platform HTTP port".to_string(), - ) - })?; - let p2p_port = platform_p2p_port.ok_or_else(|| { - PlatformWalletError::InvalidParameter( - "an evonode payload requires the platform P2P port (the masternode list does \ - not carry it)" - .to_string(), - ) - })?; - ( + let (mn_type, platform_node_id, platform_p2p_port, platform_http_port) = match service { + ConfirmedMasternodeService::Evonode { + platform_node_id, + platform_p2p_port, + platform_http_port, + .. + } => ( Some(ProviderMasternodeType::HighPerformance as u16), - Some(PlatformNodeId::from_byte_array(node_id)), - Some(p2p_port), - Some(http_port), - ) - } else { - if platform_p2p_port.is_some() { - return Err(PlatformWalletError::InvalidParameter( - "a platform P2P port was given, but this masternode is not an evonode".to_string(), - )); - } - ( + Some(PlatformNodeId::from_byte_array(*platform_node_id)), + Some(*platform_p2p_port), + Some(*platform_http_port), + ), + ConfirmedMasternodeService::Regular { .. } => ( Some(ProviderMasternodeType::Regular as u16), None, None, None, - ) + ), }; Ok(ProviderUpdateServicePayload::new( mn_type, - Txid::from_byte_array(entry.pro_tx_hash), + Txid::from_byte_array(pro_tx_hash), ip_address, port, script_payout, @@ -351,12 +579,88 @@ pub(crate) fn service_payload_fields(service: SocketAddr) -> (u128, u16) { (u128::from_le_bytes(octets), service.port()) } +/// The funding builder for a ProUpServTx: the placeholder payload, the +/// finalizer that commits it to the selected inputs and signs it, and one +/// zero-value data output. +/// +/// A ProUpServTx moves no value, so without that output a selection whose +/// surplus is below the dust limit (all of it booked as fee, no change +/// output) leaves the transaction with no outputs, which consensus refuses. +/// The data output is the placeholder Dash Core's own `protx +/// update_service` adds for the same reason, so the transaction always +/// carries an output; change is still returned whenever the surplus clears +/// the dust limit. +pub(crate) fn update_service_builder( + placeholder: ProviderUpdateServicePayload, + operator_secret: Zeroizing<[u8; 32]>, +) -> Result { + Ok(TransactionBuilder::new() + .add_op_return(&[]) + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))? + .set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType( + placeholder, + )) + .set_payload_finalizer(move |unsigned| { + finalize_update_service_payload(unsigned, &operator_secret) + })) +} + +/// The payload finalizer: write `inputs_hash` over the selected inputs and +/// the operator-BLS `payload_sig` (basic scheme over `base_payload_hash()`, +/// modern serialization: the exact convention `verify_message_digest` +/// checks real mainnet signatures with). +/// +/// Refuses a transaction without outputs before signing anything. key-wallet +/// releases the build's reservation on any finalizer error, so the refusal +/// leaves the selected inputs spendable. +pub(crate) fn finalize_update_service_payload( + unsigned: &Transaction, + operator_secret: &[u8; 32], +) -> Result { + if unsigned.output.is_empty() { + return Err(BuilderError::InvalidData( + "the assembled ProUpServTx has no outputs, which the network refuses".into(), + )); + } + let Some(TransactionPayload::ProviderUpdateServicePayloadType(placeholder)) = + &unsigned.special_transaction_payload + else { + return Err(BuilderError::InvalidData( + "the ProUpServTx placeholder payload is missing from the assembled transaction".into(), + )); + }; + let mut finalized = placeholder.clone(); + finalized.inputs_hash = unsigned.hash_inputs(); + + let secret = Option::>::from( + BlsSecretKey::::from_be_bytes(operator_secret), + ) + .ok_or_else(|| { + BuilderError::SigningFailed("the operator key is not a valid BLS secret".into()) + })?; + let signature = secret + .sign( + SignatureSchemes::Basic, + finalized.base_payload_hash().as_byte_array(), + ) + .map_err(|e| BuilderError::SigningFailed(format!("BLS payload signing failed: {e}")))?; + let signature_bytes: [u8; 96] = signature + .to_bytes_with_mode(dashcore::blsful::SerializationFormat::Modern) + .as_slice() + .try_into() + .map_err(|_| { + BuilderError::SigningFailed("BLS signature did not serialize to 96 bytes".into()) + })?; + finalized.payload_sig = BLSSignature::from(signature_bytes); + Ok(TransactionPayload::ProviderUpdateServicePayloadType( + finalized, + )) +} + /// Fund and finalize the ProUpServTx: input selection reserves the funding /// inputs, the payload finalizer writes `inputs_hash` and the operator-BLS -/// `payload_sig` (basic scheme over `base_payload_hash()`, modern -/// serialization — the exact convention `verify_message_digest` checks real -/// mainnet signatures with), and only then are the inputs ECDSA-signed, -/// since their sighashes cover the finished payload. +/// `payload_sig`, and only then are the inputs ECDSA-signed, since their +/// sighashes cover the finished payload. /// /// Stops at the signed transaction; the caller broadcasts or abandons it. pub(crate) async fn build_sign_update_service( @@ -369,52 +673,7 @@ where B: TransactionBroadcaster + ?Sized, S: TransactionSigner + ?Sized + Sync, { - let builder = TransactionBuilder::new() - .set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType( - placeholder, - )) - .set_payload_finalizer(move |unsigned| { - let Some(TransactionPayload::ProviderUpdateServicePayloadType(placeholder)) = - &unsigned.special_transaction_payload - else { - return Err(BuilderError::InvalidData( - "the ProUpServTx placeholder payload is missing from the assembled \ - transaction" - .into(), - )); - }; - let mut finalized = placeholder.clone(); - finalized.inputs_hash = unsigned.hash_inputs(); - - let secret = Option::>::from( - BlsSecretKey::::from_be_bytes(&operator_secret), - ) - .ok_or_else(|| { - BuilderError::SigningFailed("the operator key is not a valid BLS secret".into()) - })?; - let signature = secret - .sign( - SignatureSchemes::Basic, - finalized.base_payload_hash().as_byte_array(), - ) - .map_err(|e| { - BuilderError::SigningFailed(format!("BLS payload signing failed: {e}")) - })?; - let signature_bytes: [u8; 96] = signature - .to_bytes_with_mode(dashcore::blsful::SerializationFormat::Modern) - .as_slice() - .try_into() - .map_err(|_| { - BuilderError::SigningFailed( - "BLS signature did not serialize to 96 bytes".into(), - ) - })?; - finalized.payload_sig = BLSSignature::from(signature_bytes); - Ok(TransactionPayload::ProviderUpdateServicePayloadType( - finalized, - )) - }); - + let builder = update_service_builder(placeholder, operator_secret)?; core.finalize_transaction(builder, &SEND_FUNDING_SOURCES, 0, signer) .await } @@ -430,9 +689,8 @@ mod tests { use super::super::list::test_support::{evonode, masternode}; use super::*; use crate::broadcaster::BroadcastError; - use crate::test_support::funded_wallet_manager; + use crate::test_support::{funded_wallet_manager, funded_wallet_manager_with_outputs}; use dashcore::blsful::{PublicKey as BlsPublicKey, Signature as BlsSignature}; - use dashcore::Transaction; use key_wallet::account::StandardAccountType; use std::sync::{Arc, Mutex}; @@ -447,6 +705,43 @@ mod tests { entry } + /// A confirmed regular service at a documentation address, distinct + /// from every `masternode(seed)` list fixture (`10.0.0.:9999`). + fn confirmed_regular() -> ConfirmedMasternodeService { + ConfirmedMasternodeService::Regular { + service_address: "203.0.113.7:19999".parse().expect("socket address"), + } + } + + /// A confirmed evonode service whose every field differs from the + /// `evonode(seed)` list fixture (node id `seed ^ 0xFF`, HTTP port 443). + fn confirmed_evonode() -> ConfirmedMasternodeService { + ConfirmedMasternodeService::Evonode { + service_address: "203.0.113.8:19999".parse().expect("socket address"), + platform_node_id: [0x5A; 20], + platform_p2p_port: 36656, + platform_http_port: 1443, + } + } + + fn params( + pro_tx_hash: [u8; 32], + service: ConfirmedMasternodeService, + ) -> MasternodeUpdateServiceParams { + MasternodeUpdateServiceParams { + pro_tx_hash, + service, + operator_payout_address: None, + } + } + + fn registered(masternode_type: ProviderMasternodeType) -> RegistrationTerms { + RegistrationTerms { + operator_reward: 0, + masternode_type, + } + } + #[derive(Default)] struct RecordingBroadcaster { sent: Mutex>, @@ -463,6 +758,40 @@ mod tests { } } + impl RecordingBroadcaster { + fn sent_count(&self) -> usize { + self.sent.lock().expect("broadcaster lock").len() + } + } + + /// A BIP44-funded core wallet holding one UTXO per value in `outputs`, + /// with a broadcaster that records what it is given. + async fn core_with_outputs( + outputs: &[u64], + ) -> ( + CoreWallet, + Arc, + crate::test_support::WalletSigner, + ) { + let (wallet_manager, wallet_id, generation, signer) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, outputs).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let broadcaster = Arc::new(RecordingBroadcaster::default()); + let core = CoreWallet::new( + sdk, + wallet_manager, + wallet_id, + broadcaster.clone(), + generation, + ); + (core, broadcaster, signer) + } + + fn regular_placeholder() -> ProviderUpdateServicePayload { + prepare_update_service_placeholder([0x44; 32], &confirmed_regular(), ScriptBuf::new()) + .expect("placeholder") + } + /// The IPv4-mapped little-endian encoding, pinned against the known /// testnet ProUpServTx vector in dashcore's own payload tests /// (52.36.64.148:19999). @@ -525,11 +854,38 @@ mod tests { assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + /// The list entry sits at `10.0.0.17:9999`; the caller confirmed + /// `203.0.113.7:19999`. The list preflights pass and the payload carries + /// the confirmed address, because the placeholder never reads the list. #[test] - fn placeholder_for_a_regular_masternode_omits_the_platform_fields() { + fn should_build_the_payload_from_the_confirmed_address_not_the_list_entry() { let entry = operator_entry(0x11, false); - let payload = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) - .expect("regular placeholder"); + let listed = entry.service_address.expect("fixture has a plain address"); + let confirmed = confirmed_regular(); + assert_ne!( + listed, + confirmed.service_address(), + "fixture addresses differ" + ); + + check_list_entry(&entry, &OPERATOR_SECRET).expect("list preflights pass"); + let payload = update_service_placeholder( + ®istered(ProviderMasternodeType::Regular), + ¶ms(entry.pro_tx_hash, confirmed), + Network::Testnet, + ) + .expect("regular placeholder"); + + assert_eq!( + (payload.ip_address, payload.port), + service_payload_fields(confirmed.service_address()), + "the payload asserts the confirmed address" + ); + assert_ne!( + (payload.ip_address, payload.port), + service_payload_fields(listed), + "the list entry's address never reaches the payload" + ); assert_eq!( payload.version, ProviderUpdateServicePayload::CURRENT_VERSION @@ -547,48 +903,170 @@ mod tests { assert_eq!(payload.platform_http_port, None); assert_eq!(payload.inputs_hash, InputsHash::all_zeros()); assert_eq!(payload.payload_sig, BLSSignature::from([0u8; 96])); - - let err = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) - .expect_err("a platform P2P port on a regular masternode must be refused"); - assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + /// Every evonode field comes from the confirmed input: the list + /// fixture's node id and HTTP port differ from the confirmed ones. #[test] - fn placeholder_for_an_evonode_requires_and_carries_the_platform_triplet() { + fn should_take_every_evonode_field_from_the_confirmed_input() { let entry = operator_entry(0x22, true); - let payload = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) - .expect("evonode placeholder"); + let ConfirmedMasternodeService::Evonode { + service_address, + platform_node_id, + platform_p2p_port, + platform_http_port, + } = confirmed_evonode() + else { + panic!("confirmed_evonode is the evonode shape"); + }; + assert_ne!(entry.platform_node_id, Some(platform_node_id)); + assert_ne!(entry.platform_http_port, Some(platform_http_port)); + + check_list_entry(&entry, &OPERATOR_SECRET).expect("list preflights pass"); + let payload = update_service_placeholder( + ®istered(ProviderMasternodeType::HighPerformance), + ¶ms(entry.pro_tx_hash, confirmed_evonode()), + Network::Testnet, + ) + .expect("evonode placeholder"); + assert_eq!( payload.mn_type, Some(ProviderMasternodeType::HighPerformance as u16) ); + assert_eq!( + (payload.ip_address, payload.port), + service_payload_fields(service_address) + ); assert_eq!( payload.platform_node_id, - entry.platform_node_id.map(PlatformNodeId::from_byte_array) + Some(PlatformNodeId::from_byte_array(platform_node_id)) ); - assert_eq!(payload.platform_p2p_port, Some(26656)); - assert_eq!(payload.platform_http_port, entry.platform_http_port); + assert_eq!(payload.platform_p2p_port, Some(platform_p2p_port)); + assert_eq!(payload.platform_http_port, Some(platform_http_port)); + } - let err = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) - .expect_err("an evonode payload without the P2P port must be refused"); + /// The registration fixes the type, so a confirmed service of the other + /// shape is refused before any payload exists. + #[test] + fn should_refuse_a_service_type_that_differs_from_the_registration() { + let err = update_service_placeholder( + ®istered(ProviderMasternodeType::HighPerformance), + ¶ms([0x33; 32], confirmed_regular()), + Network::Testnet, + ) + .expect_err("a registered evonode needs its platform fields"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let err = update_service_placeholder( + ®istered(ProviderMasternodeType::Regular), + ¶ms([0x33; 32], confirmed_evonode()), + Network::Testnet, + ) + .expect_err("a registered regular masternode has no platform fields"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + #[test] + fn should_refuse_confirmed_service_values_no_payload_can_carry() { + let regular = |address: &str| ConfirmedMasternodeService::Regular { + service_address: address.parse().expect("socket address"), + }; + for address in ["[2001:db8::1]:19999", "0.0.0.0:19999", "203.0.113.7:0"] { + let err = validate_confirmed_service(®ular(address)) + .expect_err("an address no version-2 payload can carry"); + assert!( + matches!(err, PlatformWalletError::InvalidParameter(_)), + "{address}" + ); + } + validate_confirmed_service(®ular("[::ffff:203.0.113.7]:19999")) + .expect("an IPv4-mapped address is IPv4"); + + let evonode = + |node_id: [u8; 20], p2p: u16, http: u16| ConfirmedMasternodeService::Evonode { + service_address: "203.0.113.8:19999".parse().expect("socket address"), + platform_node_id: node_id, + platform_p2p_port: p2p, + platform_http_port: http, + }; + for (label, service) in [ + ("empty node id", evonode([0; 20], 36656, 1443)), + ("no P2P port", evonode([1; 20], 0, 1443)), + ("no HTTP port", evonode([1; 20], 36656, 0)), + ("P2P equals HTTP", evonode([1; 20], 1443, 1443)), + ("P2P equals Core", evonode([1; 20], 19999, 1443)), + ("HTTP equals Core", evonode([1; 20], 36656, 19999)), + ] { + let err = validate_confirmed_service(&service).expect_err(label); + assert!( + matches!(err, PlatformWalletError::InvalidParameter(_)), + "{label}" + ); + } + validate_confirmed_service(&confirmed_evonode()).expect("a complete evonode service"); + } + + #[test] + fn should_parse_a_service_address_only_with_a_port() { + assert_eq!( + parse_service_address(" 203.0.113.7:19999 ").expect("IP:port"), + "203.0.113.7:19999" + .parse::() + .expect("socket address") + ); + for text in ["203.0.113.7", "node.example:19999", ""] { + let err = parse_service_address(text).expect_err(text); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } } + /// A v2 payload would replace the whole endpoint map with one address, + /// so the list's extended-net-info flag refuses even though a routable + /// primary exists. #[test] - fn placeholder_refuses_a_v3_extended_net_info_entry() { - // A v2 payload would replace the whole endpoint map with the primary - // address — refuse even though a routable primary exists. + fn should_refuse_a_list_entry_with_extended_net_info() { let mut entry = operator_entry(0x55, false); entry.has_extended_net_info = true; assert!( entry.service_address.is_some(), "primary address present and routable" ); - let err = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + let err = check_list_entry(&entry, &OPERATOR_SECRET) .expect_err("an extended-net-info entry must be refused"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + /// The caller supplies the address, so a list entry without a plain + /// IP:port (a Tor-only entry, say) no longer blocks the update. + #[test] + fn should_accept_a_list_entry_without_a_plain_service_address() { + let mut entry = operator_entry(0x33, false); + entry.service_address = None; + check_list_entry(&entry, &OPERATOR_SECRET).expect("the list address is not needed"); + } + + #[test] + fn should_suggest_the_list_entry_with_its_banned_status() { + let mut entry = evonode(0x66); + entry.is_valid = false; + let suggestion = MasternodeServiceSuggestion::from_summary(&entry); + assert_eq!(suggestion.service_address, entry.service_address); + assert!(suggestion.is_evonode); + assert_eq!(suggestion.platform_node_id, entry.platform_node_id); + assert_eq!(suggestion.platform_http_port, entry.platform_http_port); + assert!(suggestion.pose_banned); + assert!(!suggestion.has_extended_net_info); + + let healthy = MasternodeServiceSuggestion::from_summary(&masternode(0x67)); + assert!( + !healthy.pose_banned, + "a valid entry is reported as not banned" + ); + assert!(!healthy.is_evonode); + assert_eq!(healthy.platform_node_id, None); + } + fn registration_transaction(operator_reward: u16) -> Transaction { use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderRegistrationPayload; use dashcore::bls_sig_utils::BLSPublicKey; @@ -628,15 +1106,21 @@ mod tests { /// trusted only after the decoded transaction hashes to the requested /// proTxHash. #[test] - fn operator_reward_binds_the_fetched_transaction_to_the_request() { + fn should_bind_the_registration_to_the_requested_pro_tx_hash() { let transaction = registration_transaction(500); let matching = transaction.txid().to_byte_array(); - let reward = operator_reward_from_registration(&matching, &transaction) + let terms = registration_terms(&matching, &transaction) .expect("a matching registration transaction is accepted"); - assert_eq!(reward, 500); + assert_eq!( + terms, + RegistrationTerms { + operator_reward: 500, + masternode_type: ProviderMasternodeType::Regular, + } + ); - let err = operator_reward_from_registration(&[0x99; 32], &transaction) + let err = registration_terms(&[0x99; 32], &transaction) .expect_err("a transaction that does not hash to the request must be refused"); assert!(matches!(err, PlatformWalletError::InvalidIdentityData(_))); @@ -644,22 +1128,13 @@ mod tests { let mut not_registration = registration_transaction(0); not_registration.special_transaction_payload = None; let plain_txid = not_registration.txid().to_byte_array(); - let err = operator_reward_from_registration(&plain_txid, ¬_registration) + let err = registration_terms(&plain_txid, ¬_registration) .expect_err("a non-registration transaction must be refused"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } - #[test] - fn placeholder_refuses_an_entry_without_a_plain_service_address() { - let mut entry = operator_entry(0x33, false); - entry.service_address = None; - let err = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) - .expect_err("no service address to re-assert"); - assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); - } - #[tokio::test] - async fn builds_signs_and_broadcasts_a_pro_up_serv_tx() { + async fn should_build_sign_and_broadcast_a_pro_up_serv_tx() { let (wallet_manager, wallet_id, generation, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); @@ -672,9 +1147,9 @@ mod tests { generation, ); - let entry = operator_entry(0x44, true); - let placeholder = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) - .expect("placeholder"); + let placeholder = + prepare_update_service_placeholder([0x44; 32], &confirmed_evonode(), ScriptBuf::new()) + .expect("placeholder"); let prepared = build_sign_update_service(&core, placeholder, Zeroizing::new(OPERATOR_SECRET), &signer) @@ -683,14 +1158,7 @@ mod tests { // Building signs but must not send: the preview flow shows this exact // transaction before the user decides. - assert!( - broadcaster - .sent - .lock() - .expect("broadcaster lock") - .is_empty(), - "preparing must not broadcast" - ); + assert_eq!(broadcaster.sent_count(), 0, "preparing must not broadcast"); let txid = core .broadcast_finalized_transaction(&prepared) @@ -702,6 +1170,10 @@ mod tests { let tx = &sent[0]; assert_eq!(tx.txid(), txid); assert_eq!(tx.version, 3); + assert!( + !tx.output.is_empty(), + "a ProUpServTx always carries an output" + ); assert!( tx.input.iter().all(|input| !input.script_sig.is_empty()), "every funding input is ECDSA-signed" @@ -721,12 +1193,12 @@ mod tests { payload.mn_type, Some(ProviderMasternodeType::HighPerformance as u16) ); - assert_eq!(payload.platform_p2p_port, Some(26656)); - assert_eq!(payload.platform_http_port, entry.platform_http_port); + assert_eq!(payload.platform_p2p_port, Some(36656)); + assert_eq!(payload.platform_http_port, Some(1443)); assert_ne!(payload.payload_sig, BLSSignature::from([0u8; 96])); // The payload signature verifies under the basic scheme against the - // operator public key, over base_payload_hash — the exact convention + // operator public key, over base_payload_hash: the exact convention // `verify_message_digest` checks real mainnet signatures with. let secret = Option::>::from( BlsSecretKey::::from_be_bytes(&OPERATOR_SECRET), @@ -741,4 +1213,116 @@ mod tests { .verify(&public_key, payload.base_payload_hash().as_byte_array()) .expect("operator BLS signature verifies over base_payload_hash"); } + + /// A lone 600-duff UTXO covers the fee but leaves less than the dust + /// limit over, so no change output is added. The zero-value data output + /// still gives the transaction an output. + #[tokio::test] + async fn should_carry_an_output_when_the_only_utxo_leaves_no_change() { + let (core, broadcaster, signer) = core_with_outputs(&[600]).await; + + let prepared = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("600 duffs pay the fee"); + + let tx = prepared.transaction(); + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.output.len(), 1, "one output even with no change"); + assert!(tx.output[0].script_pubkey.is_op_return()); + assert_eq!(tx.output[0].value, 0, "the data output moves no value"); + assert_eq!(prepared.fee(), 600, "the sub-dust surplus is the fee"); + assert_eq!(broadcaster.sent_count(), 0, "preparing must not broadcast"); + } + + /// Branch-and-bound prefers the small UTXO, whose surplus is booked as + /// fee; the transaction still carries an output. + #[tokio::test] + async fn should_carry_an_output_when_selection_prefers_the_small_utxo() { + let (core, _broadcaster, signer) = core_with_outputs(&[1_000_000_000, 600]).await; + + let prepared = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("update service builds"); + + let tx = prepared.transaction(); + assert!( + !tx.output.is_empty(), + "a ProUpServTx always carries an output" + ); + let Some(TransactionPayload::ProviderUpdateServicePayloadType(payload)) = + &tx.special_transaction_payload + else { + panic!("the transaction must carry the ProUpServTx payload"); + }; + assert_eq!(payload.inputs_hash, tx.hash_inputs()); + } + + /// A wallet that cannot cover the fee fails in coin selection, before + /// anything is reserved, signed or sent. + #[tokio::test] + async fn should_fail_before_broadcast_when_the_fee_cannot_be_covered() { + let (core, broadcaster, signer) = core_with_outputs(&[200]).await; + + let err = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect_err("200 duffs cannot pay the fee"); + + assert!( + matches!(err, PlatformWalletError::CorePooledInsufficientFunds { .. }), + "an insufficient-funds error, got {err:?}" + ); + assert_eq!(broadcaster.sent_count(), 0, "nothing is broadcast"); + } + + /// Without the data output a lone 600-duff UTXO assembles into a + /// transaction with no outputs. The finalizer refuses it before signing, + /// and the refusal releases the reservation: the same UTXO funds the + /// next build. + #[tokio::test] + async fn should_release_the_reservation_when_the_transaction_has_no_outputs() { + let (core, broadcaster, signer) = core_with_outputs(&[600]).await; + + let secret = Zeroizing::new(OPERATOR_SECRET); + let without_output = TransactionBuilder::new() + .set_special_payload(TransactionPayload::ProviderUpdateServicePayloadType( + regular_placeholder(), + )) + .set_payload_finalizer(move |unsigned| { + finalize_update_service_payload(unsigned, &secret) + }); + let err = core + .finalize_transaction(without_output, &SEND_FUNDING_SOURCES, 0, &signer) + .await + .expect_err("a transaction with no outputs is refused"); + assert!( + matches!(&err, PlatformWalletError::TransactionBuild(message) if message.contains("no outputs")), + "the refusal names the missing outputs, got {err:?}" + ); + assert_eq!(broadcaster.sent_count(), 0, "nothing is broadcast"); + + let rebuilt = build_sign_update_service( + &core, + regular_placeholder(), + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("the released UTXO funds the next build"); + assert_eq!(rebuilt.transaction().input.len(), 1); + } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 4c2bc2764b5..112145d2c8f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -358,29 +358,84 @@ extension PlatformWalletManager { }.value } - /// Unban / update-service: broadcast a ProUpServTx re-asserting this - /// wallet-owned masternode's current service values — which revives it - /// if it is PoSe-banned. Pure bridge — the whole orchestration (list - /// lookup, operator-key derive + match, payout rule, funding, BLS + /// What the synced masternode list shows for a masternode's service, + /// to prefill the update-service form. A hint only: show every value + /// and have the user confirm or correct it before building the + /// `MasternodeServiceConfirmation` an update-service call takes. The + /// list does not carry an evonode's platform P2P port, so that one is + /// always entered. + /// + /// `poseBanned` lets the form warn when the node looks healthy; the + /// update itself never refuses on it. Throws `.notFound` when the list + /// has no such masternode and `.masternodeListUnavailable` before the + /// list has synced. Local; no network. + public func masternodeUpdateServiceSuggestion( + proTxHash: Data + ) async throws -> MasternodeServiceSuggestion { + guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or proTxHash not 32 bytes") + } + + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> MasternodeServiceSuggestion in + var out = MasternodeServiceSuggestionFFI() + let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_manager_masternode_update_service_suggestion( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &out + ) + } + // Rust heap-allocates the address string on success; copy it + // into Swift and free it on every path. + defer { + if out.service_address != nil { + platform_wallet_string_free(out.service_address) + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return MasternodeServiceSuggestion( + serviceAddress: out.service_address.map { String(cString: $0) }, + isEvonode: out.is_evonode, + platformNodeId: out.has_platform_node_id + ? withUnsafeBytes(of: &out.platform_node_id) { Data($0) } + : nil, + platformHTTPPort: out.has_platform_http_port ? out.platform_http_port : nil, + poseBanned: out.pose_banned, + hasExtendedNetInfo: out.has_extended_net_info + ) + }.value + } + + /// Update service (which also unbans): broadcast a ProUpServTx + /// asserting the service values the user confirmed for this + /// wallet-owned masternode, which revives it if it is PoSe-banned. + /// Pure bridge: the whole orchestration (list lookup, operator-key + /// derive + match, registration check, payout rule, funding, BLS /// payload sign, input sign, broadcast) lives in `platform-wallet` /// behind this one FFI call, per CLAUDE.md. /// /// - `operatorKeyIndex`: the wallet's operator-key index for this - /// masternode — the record's `operatorKeyIndex` join field. - /// - `platformP2PPort`: required for an evonode (the masternode list - /// does not carry it); must be nil for a regular masternode. + /// masternode, the record's `operatorKeyIndex` join field. + /// - `service`: the confirmed service values; the payload carries these, + /// not the masternode list's entry. Prefill them from + /// `masternodeUpdateServiceSuggestion(proTxHash:)`. /// - `operatorPayoutAddress`: must be nil when the masternode's /// registered `operatorReward` is 0, and must be given when it is - /// not — the payload REPLACES the operator payout script on-chain. + /// not; the payload REPLACES the operator payout script on-chain. /// /// Returns the ProUpServTx txid (32 wire-order bytes). A /// `.transactionBroadcastUnconfirmed` error means the outcome is - /// ambiguous — never retry; the wallet reconciles through sync. + /// ambiguous: never retry; the wallet reconciles through sync. public func masternodeUpdateService( walletId: Data, proTxHash: Data, operatorKeyIndex: UInt32, - platformP2PPort: UInt16? = nil, + service: MasternodeServiceConfirmation, operatorPayoutAddress: String? = nil ) async throws -> Data { guard isConfigured, handle != NULL_HANDLE, @@ -389,6 +444,7 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } + try service.validateShape() let handle = self.handle return try await Task.detached(priority: .userInitiated) { () -> Data in @@ -409,23 +465,27 @@ extension PlatformWalletManager { let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_masternode_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - operatorKeyIndex, - platformP2PPort != nil, - platformP2PPort ?? 0, - payoutPtr, - resolver.handle, - &txidTuple - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } + service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + operatorKeyIndex, + cAddress, + nodeId, + p2pPort, + httpPort, + payoutPtr, + resolver.handle, + &txidTuple + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) } - return call(nil) } } } @@ -437,20 +497,20 @@ extension PlatformWalletManager { }.value } - /// Prepare — but do not broadcast — the same ProUpServTx + /// Prepare, but do not broadcast, the same ProUpServTx /// `masternodeUpdateService` would send, so the host can show the /// transaction before the user commits to it. /// /// The returned token owns a signed transaction whose inputs are /// reserved: broadcast it with /// `ManagedCoreWallet.broadcastTransactionWithOutcome(_:)`, or let it - /// deinit — which abandons it and releases the reservation. Its `fee` + /// deinit, which abandons it and releases the reservation. Its `fee` /// and `serializedData()` describe exactly what a broadcast would send. public func masternodePrepareUpdateService( walletId: Data, proTxHash: Data, operatorKeyIndex: UInt32, - platformP2PPort: UInt16? = nil, + service: MasternodeServiceConfirmation, operatorPayoutAddress: String? = nil ) async throws -> FinalizedCoreTransaction { guard isConfigured, handle != NULL_HANDLE, @@ -459,6 +519,7 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } + try service.validateShape() let handle = self.handle // Only the raw handle crosses the task boundary; the owning token is @@ -469,23 +530,27 @@ extension PlatformWalletManager { let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_masternode_prepare_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - operatorKeyIndex, - platformP2PPort != nil, - platformP2PPort ?? 0, - payoutPtr, - resolver.handle, - &outHandle - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } + service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_prepare_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + operatorKeyIndex, + cAddress, + nodeId, + p2pPort, + httpPort, + payoutPtr, + resolver.handle, + &outHandle + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) } - return call(nil) } } } @@ -500,6 +565,112 @@ extension PlatformWalletManager { } } +/// The service values an update-service ProUpServTx asserts, as the user +/// confirmed them. The payload is built from these values alone. +/// +/// `PlatformWalletManager.masternodeUpdateServiceSuggestion(proTxHash:)` +/// offers what the synced masternode list shows to prefill a form; that is a +/// hint, so show every value and let the user confirm or correct it before +/// building this. The shape (regular or evonode) must match how the +/// masternode was registered; the update refuses a mismatch. +public struct MasternodeServiceConfirmation: Sendable, Equatable { + /// Core P2P endpoint as `"a.b.c.d:port"`. IPv4, and the port is + /// required. + public let serviceAddress: String + /// The evonode platform fields; `nil` for a regular masternode. + public let evonode: EvonodePlatformService? + + public init(serviceAddress: String, evonode: EvonodePlatformService? = nil) { + self.serviceAddress = serviceAddress + self.evonode = evonode + } + + /// The Swift-side shape check the FFI relies on: an evonode's node id + /// must be 20 bytes, since an empty `Data` would reach Rust as a null + /// pointer, which means a regular masternode. + func validateShape() throws { + if let evonode, evonode.platformNodeId.count != 20 { + throw PlatformWalletError.invalidParameter( + "the platform node id must be 20 bytes") + } + } + + /// Calls `body` with the C views the update-service externs take: the + /// address as a C string, then the node id (null for a regular + /// masternode) and the two platform ports (0 for a regular masternode). + /// Call `validateShape()` first. + func withFFIArguments( + _ body: (UnsafePointer, UnsafePointer?, UInt16, UInt16) -> R + ) -> R { + serviceAddress.withCString { cAddress in + guard let evonode else { + return body(cAddress, nil, 0, 0) + } + return evonode.platformNodeId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> R in + body( + cAddress, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + evonode.platformP2PPort, + evonode.platformHTTPPort + ) + } + } + } +} + +/// The platform fields of an evonode's update-service payload. +public struct EvonodePlatformService: Sendable, Equatable { + /// Tenderdash node id, 20 bytes. + public let platformNodeId: Data + /// Tenderdash P2P port. The masternode list does not carry it. + public let platformP2PPort: UInt16 + /// Platform HTTP (DAPI) port. + public let platformHTTPPort: UInt16 + + public init(platformNodeId: Data, platformP2PPort: UInt16, platformHTTPPort: UInt16) { + self.platformNodeId = platformNodeId + self.platformP2PPort = platformP2PPort + self.platformHTTPPort = platformHTTPPort + } +} + +/// What the synced masternode list shows for a masternode's service. See +/// `PlatformWalletManager.masternodeUpdateServiceSuggestion(proTxHash:)`: +/// a prefill hint the user confirms, never a payload source. +public struct MasternodeServiceSuggestion: Sendable, Equatable { + /// `"ip:port"` of the Core P2P endpoint the list shows; `nil` for Tor / + /// I2P / domain-only entries. + public let serviceAddress: String? + /// The list shows an evonode entry. + public let isEvonode: Bool + /// Tenderdash node id (20 bytes), evonodes only. + public let platformNodeId: Data? + /// Platform HTTP (DAPI) port, evonodes only. + public let platformHTTPPort: UInt16? + /// The list shows the masternode PoSe-banned. When false, the form may + /// warn that the node looks healthy; updating it is still allowed. + public let poseBanned: Bool + /// The list shows v3 extended network info, which the update refuses + /// (a version-2 payload would replace the whole endpoint map). + public let hasExtendedNetInfo: Bool + + public init( + serviceAddress: String?, + isEvonode: Bool, + platformNodeId: Data?, + platformHTTPPort: UInt16?, + poseBanned: Bool, + hasExtendedNetInfo: Bool + ) { + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.platformNodeId = platformNodeId + self.platformHTTPPort = platformHTTPPort + self.poseBanned = poseBanned + self.hasExtendedNetInfo = hasExtendedNetInfo + } +} + /// Which wallet key signs a masternode (evonode) credit withdrawal. public enum MasternodeWithdrawalSigningKey: Sendable, Equatable { /// The `ProviderOwnerKeys` key — pays the registered payout address only. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift index e2c21d4c74f..b78527b7552 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift @@ -215,28 +215,29 @@ extension PlatformWalletManager { }.value } - /// Unban / update-service for a tracked masternode: broadcast a - /// ProUpServTx re-asserting its current service values — which revives - /// it if it is PoSe-banned — signed with the host-vaulted operator key - /// text (64-char hex or 32-byte base64). The L1 network fee is funded - /// from `walletId`'s core funds, so this call — unlike - /// `trackedMasternodeWithdraw` — also signs wallet inputs through the - /// mnemonic resolver. + /// Update service (which also unbans) for a tracked masternode: + /// broadcast a ProUpServTx asserting the service values the user + /// confirmed, which revives it if it is PoSe-banned, signed with the + /// host-vaulted operator key text (64-char hex or 32-byte base64). The + /// L1 network fee is funded from `walletId`'s core funds, so this call, + /// unlike `trackedMasternodeWithdraw`, also signs wallet inputs through + /// the mnemonic resolver. /// - /// - `platformP2PPort`: required for an evonode (the masternode list - /// does not carry it); must be nil for a regular masternode. + /// - `service`: the confirmed service values; the payload carries these, + /// not the masternode list's entry. Prefill them from + /// `masternodeUpdateServiceSuggestion(proTxHash:)`. /// - `operatorPayoutAddress`: must be nil when the masternode's /// registered `operatorReward` is 0, and must be given when it is - /// not — the payload REPLACES the operator payout script on-chain. + /// not; the payload REPLACES the operator payout script on-chain. /// /// Returns the ProUpServTx txid (32 wire-order bytes). A /// `.transactionBroadcastUnconfirmed` error means the outcome is - /// ambiguous — never retry; the wallet reconciles through sync. + /// ambiguous: never retry; the wallet reconciles through sync. public func trackedMasternodeUpdateService( walletId: Data, proTxHash: Data, operatorKey: String, - platformP2PPort: UInt16? = nil, + service: MasternodeServiceConfirmation, operatorPayoutAddress: String? = nil ) async throws -> Data { guard isConfigured, handle != NULL_HANDLE, @@ -245,6 +246,7 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } + try service.validateShape() let handle = self.handle return try await Task.detached(priority: .userInitiated) { () -> Data in @@ -264,23 +266,27 @@ extension PlatformWalletManager { walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in operatorKey.withCString { cKey -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_tracked_masternode_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - cKey, - platformP2PPort != nil, - platformP2PPort ?? 0, - payoutPtr, - resolver.handle, - &txidTuple - ) + service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_tracked_masternode_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + cAddress, + nodeId, + p2pPort, + httpPort, + payoutPtr, + resolver.handle, + &txidTuple + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } - } - return call(nil) } } } @@ -293,18 +299,18 @@ extension PlatformWalletManager { }.value } - /// Prepare — but do not broadcast — the same ProUpServTx + /// Prepare, but do not broadcast, the same ProUpServTx /// `trackedMasternodeUpdateService` would send, so the host can show the /// transaction before the user commits to it. /// /// Ownership matches `masternodePrepareUpdateService`: the returned - /// token holds a signed transaction with reserved inputs — broadcast it, + /// token holds a signed transaction with reserved inputs; broadcast it, /// or let it deinit, which abandons it and releases the reservation. public func trackedMasternodePrepareUpdateService( walletId: Data, proTxHash: Data, operatorKey: String, - platformP2PPort: UInt16? = nil, + service: MasternodeServiceConfirmation, operatorPayoutAddress: String? = nil ) async throws -> FinalizedCoreTransaction { guard isConfigured, handle != NULL_HANDLE, @@ -313,6 +319,7 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } + try service.validateShape() let handle = self.handle // Only the raw handle crosses the task boundary; the owning token is @@ -324,23 +331,27 @@ extension PlatformWalletManager { walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in operatorKey.withCString { cKey -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_tracked_masternode_prepare_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - cKey, - platformP2PPort != nil, - platformP2PPort ?? 0, - payoutPtr, - resolver.handle, - &outHandle - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } + service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_tracked_masternode_prepare_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + cAddress, + nodeId, + p2pPort, + httpPort, + payoutPtr, + resolver.handle, + &outHandle + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) } - return call(nil) } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift new file mode 100644 index 00000000000..1744077b88a --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift @@ -0,0 +1,59 @@ +import XCTest + +@testable import SwiftDashSDK + +/// The confirmed service values reach the update-service externs in the +/// shape Rust reads them: a null node id and zero ports for a regular +/// masternode, the 20-byte node id and both ports for an evonode. +final class MasternodeServiceConfirmationTests: XCTestCase { + func testShouldPassNoPlatformFieldsForARegularMasternode() throws { + let service = MasternodeServiceConfirmation(serviceAddress: "203.0.113.7:9999") + try service.validateShape() + + let (address, hasNodeId, p2pPort, httpPort) = service.withFFIArguments { + cAddress, nodeId, p2pPort, httpPort in + (String(cString: cAddress), nodeId != nil, p2pPort, httpPort) + } + XCTAssertEqual(address, "203.0.113.7:9999") + XCTAssertFalse(hasNodeId, "a null node id is what marks a regular masternode") + XCTAssertEqual(p2pPort, 0) + XCTAssertEqual(httpPort, 0) + } + + func testShouldPassEveryConfirmedFieldForAnEvonode() throws { + let nodeId = Data(repeating: 0x5A, count: 20) + let service = MasternodeServiceConfirmation( + serviceAddress: "203.0.113.8:9999", + evonode: EvonodePlatformService( + platformNodeId: nodeId, platformP2PPort: 26656, platformHTTPPort: 443)) + try service.validateShape() + + let (address, passedNodeId, p2pPort, httpPort) = service.withFFIArguments { + cAddress, nodeIdPtr, p2pPort, httpPort in + ( + String(cString: cAddress), + nodeIdPtr.map { Data(bytes: $0, count: 20) }, + p2pPort, + httpPort + ) + } + XCTAssertEqual(address, "203.0.113.8:9999") + XCTAssertEqual(passedNodeId, nodeId) + XCTAssertEqual(p2pPort, 26656) + XCTAssertEqual(httpPort, 443) + } + + /// An empty `Data` would reach Rust as a null pointer, which reads as a + /// regular masternode, so a short node id is refused on the Swift side. + func testShouldRefuseAnEvonodeNodeIdThatIsNotTwentyBytes() { + for length in [0, 19, 21] { + let service = MasternodeServiceConfirmation( + serviceAddress: "203.0.113.8:9999", + evonode: EvonodePlatformService( + platformNodeId: Data(repeating: 1, count: length), + platformP2PPort: 26656, + platformHTTPPort: 443)) + XCTAssertThrowsError(try service.validateShape(), "length \(length)") + } + } +} From c939f016af8f8da66fdae30d01723784bf448e04 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 29 Sep 2026 01:03:37 +0700 Subject: [PATCH 2/2] fix(platform-wallet): a ProUpServTx always carries an output Keep only the output fix in this change. The service address, masternode type and platform fields come from the synced masternode list as on v4.2-dev, and the FFI entry points and Swift methods are back to their v4.2-dev form. Every ProUpServTx gets a zero-value OP_RETURN output, as Dash Core's FundSpecialTx adds for protx update_service, and the payload finalizer refuses a transaction without outputs before signing. The comment on reading the registration transaction now says the fetched transaction must hash to the requested proTxHash, that only the operator reward is read from it, and that the service values come from the synced list. Co-Authored-By: Claude Opus 5.5 --- .../src/masternode_update_service.rs | 580 +++----------- .../rs-platform-wallet/src/masternode/mod.rs | 3 +- .../src/masternode/update_service.rs | 709 ++++-------------- .../PlatformWalletManagerMasternodes.swift | 261 ++----- ...tformWalletManagerTrackedMasternodes.swift | 105 ++- .../MasternodeServiceConfirmationTests.swift | 59 -- 6 files changed, 358 insertions(+), 1359 deletions(-) delete mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs index 29d06006d53..f716669be84 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs @@ -1,10 +1,10 @@ //! FFI bindings for the masternode update-service (ProUpServTx / unban) //! action — `platform_wallet::masternode::update_service`. //! -//! Two entry points, mirroring the withdraw pair, each with a prepare-only -//! twin. All fund the L1 fee from `wallet_id`'s core funds (input signing -//! goes through the host's mnemonic resolver, like every wallet-key signing -//! path); the pairs differ only in where the operator BLS key comes from: +//! Two entry points, mirroring the withdraw pair. Both fund the L1 fee from +//! `wallet_id`'s core funds (input signing goes through the host's mnemonic +//! resolver, like every wallet-key signing path); they differ only in where +//! the operator BLS key comes from: //! //! - [`platform_wallet_manager_masternode_update_service`][]: wallet-owned //! masternodes — the operator key is derived from the wallet's @@ -15,28 +15,20 @@ //! hex or 32-byte base64), parsed and matched exactly like //! `platform_wallet_manager_masternode_verify_key`. //! -//! The service values are an explicit input the host has the user confirm: -//! `service_address` (`"a.b.c.d:port"`) always, and for an evonode -//! `platform_node_id` (20 bytes) with both platform ports. The payload is -//! built from that input alone. -//! [`platform_wallet_manager_masternode_update_service_suggestion`] reads -//! what the synced masternode list shows, as a hint to prefill the form. -//! -//! `out_txid` (32 wire-order bytes) is written only when the broadcast -//! definitively succeeded; an ambiguous outcome returns +//! The action is revive-only: service values are copied from the live +//! masternode-list entry. `out_txid` (32 wire-order bytes) is written only +//! when the broadcast definitively succeeded; an ambiguous outcome returns //! `ErrorTransactionBroadcastUnconfirmed` and the reserved inputs stay held -//! for the wallet's normal reconciliation. Never retry the call on that +//! for the wallet's normal reconciliation — never retry the call on that //! code. -use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::sync::Arc; use dashcore::hashes::Hash; use platform_wallet::masternode::{ - execute_masternode_update_service, masternode_update_service_suggestion, parse_secret_for_role, - parse_service_address, prepare_masternode_update_service, ConfirmedMasternodeService, - LocatorSecret, MasternodeKeyRole, MasternodeServiceSuggestion, MasternodeUpdateServiceParams, + execute_masternode_update_service, parse_secret_for_role, prepare_masternode_update_service, + LocatorSecret, MasternodeKeyRole, MasternodeUpdateServiceParams, }; use platform_wallet::{PlatformWallet, ProviderKeyKind}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; @@ -177,52 +169,40 @@ fn tracked_operator_secret( } } -/// Marshal the confirmed service values and the rest of the request. -/// `platform_node_id` selects the shape: null for a regular masternode -/// (whose platform ports must then be 0), 20 bytes for an evonode (whose -/// ports the library then requires). unsafe fn marshal_params( pro_tx_hash: *const u8, - service_address: *const c_char, - platform_node_id: *const u8, + has_platform_p2p_port: bool, platform_p2p_port: u16, - platform_http_port: u16, operator_payout_address: *const c_char, ) -> Result { - let service_text = CStr::from_ptr(service_address).to_str()?; - let service_address = - parse_service_address(service_text).map_err(PlatformWalletFFIResult::from)?; - let service = if platform_node_id.is_null() { - if platform_p2p_port != 0 || platform_http_port != 0 { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "platform ports were given without a platform node id; they apply only to an \ - evonode", - )); - } - ConfirmedMasternodeService::Regular { service_address } - } else { - ConfirmedMasternodeService::Evonode { - service_address, - platform_node_id: std::ptr::read(platform_node_id as *const [u8; 20]), - platform_p2p_port, - platform_http_port, - } - }; Ok(MasternodeUpdateServiceParams { pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), - service, + platform_p2p_port: has_platform_p2p_port.then_some(platform_p2p_port), operator_payout_address: optional_string(operator_payout_address)?, }) } +#[allow(clippy::too_many_arguments)] unsafe fn run_update_service( context: ResolvedContext, - params: MasternodeUpdateServiceParams, + pro_tx_hash: *const u8, operator_secret: Zeroizing<[u8; 32]>, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], ) -> PlatformWalletFFIResult { + let params = match marshal_params( + pro_tx_hash, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + let ResolvedContext { wallet, spv, @@ -250,13 +230,27 @@ unsafe fn run_update_service( /// reservation — as a core signed-transaction handle the host later /// broadcasts (`core_wallet_broadcast_signed_transaction`), abandons /// (`core_wallet_abandon_signed_transaction`) or frees (which abandons). +#[allow(clippy::too_many_arguments)] unsafe fn run_prepare_update_service( context: ResolvedContext, - params: MasternodeUpdateServiceParams, + pro_tx_hash: *const u8, operator_secret: Zeroizing<[u8; 32]>, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_transaction_handle: *mut Handle, ) -> PlatformWalletFFIResult { + let params = match marshal_params( + pro_tx_hash, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + let ResolvedContext { wallet, spv, @@ -282,34 +276,29 @@ unsafe fn run_prepare_update_service( PlatformWalletFFIResult::ok() } -/// Broadcast a ProUpServTx asserting a wallet-owned masternode's confirmed -/// service values, which also revives it if it is PoSe-banned, signed with -/// the wallet's operator key at `operator_key_index` (the index the -/// masternode record's `operator_key_index` join field reports). +/// Broadcast a ProUpServTx re-asserting a wallet-owned masternode's current +/// service values — which revives it if it is PoSe-banned — signed with the +/// wallet's operator key at `operator_key_index` (the index the masternode +/// record's `operator_key_index` join field reports). /// -/// - `wallet_id` / `pro_tx_hash`: 32 bytes each; `pro_tx_hash` in WIRE +/// - `wallet_id` / `pro_tx_hash` — 32 bytes each; `pro_tx_hash` in WIRE /// order, as the masternode list reports it. -/// - `service_address`: required NUL-terminated `"a.b.c.d:port"`, the Core -/// P2P endpoint the user confirmed. The payload carries it, not the -/// masternode list's entry. -/// - `platform_node_id`: null for a regular masternode, or 20 bytes for an -/// evonode, which then also needs `platform_p2p_port` and -/// `platform_http_port`. A regular masternode passes 0 for both ports. -/// The shape must match the masternode's registration. -/// - `operator_payout_address`: nullable. Must be null when the ProRegTx's +/// - `platform_p2p_port` (honoured when `has_platform_p2p_port`) — required +/// for an evonode, forbidden otherwise; the masternode list does not +/// carry it. +/// - `operator_payout_address` — nullable. Must be null when the ProRegTx's /// `operatorReward` is 0, and must be given when it is not (the payload /// REPLACES the payout script on-chain; an empty one would clear it). -/// - `out_txid`: 32 wire-order bytes, written on definitive success. +/// - `out_txid` — 32 wire-order bytes, written on definitive success. /// /// On `ErrorTransactionBroadcastUnconfirmed` the outcome is ambiguous: the -/// reserved inputs stay held and the wallet reconciles through sync. Do +/// reserved inputs stay held and the wallet reconciles through sync — do /// not retry. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `service_address` must -/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come -/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the -/// duration of the call. +/// Pointer args must be valid for the stated sizes; `mnemonic_resolver_handle` +/// must come from `dash_sdk_mnemonic_resolver_create` and remain valid for +/// the duration of the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( @@ -317,10 +306,8 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_index: u32, - service_address: *const c_char, - platform_node_id: *const u8, + has_platform_p2p_port: bool, platform_p2p_port: u16, - platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], @@ -331,21 +318,8 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( *out_txid = [0u8; 32]; check_ptr!(wallet_id); check_ptr!(pro_tx_hash); - check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); - // Marshal first so malformed input fails before the resolver is used. - let params = match marshal_params( - pro_tx_hash, - service_address, - platform_node_id, - platform_p2p_port, - platform_http_port, - operator_payout_address, - ) { - Ok(params) => params, - Err(e) => return e, - }; let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, Err(e) => return e, @@ -360,15 +334,18 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( }; run_update_service( context, - params, + pro_tx_hash, operator_secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, mnemonic_resolver_handle, out_txid, ) } -/// Broadcast a ProUpServTx asserting a masternode's confirmed service -/// values, which also revives it if it is PoSe-banned, signed with a +/// Broadcast a ProUpServTx re-asserting a masternode's current service +/// values — which revives it if it is PoSe-banned — signed with a /// host-supplied operator key (the tracked-masternode vault's key text: /// 64-char hex or 32-byte base64). The L1 fee is still funded from /// `wallet_id`'s core funds through the mnemonic resolver. @@ -380,11 +357,10 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( /// serialization) before any network work. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `operator_key_text` and -/// `service_address` must be NUL-terminated UTF-8 strings; -/// `mnemonic_resolver_handle` must come from -/// `dash_sdk_mnemonic_resolver_create` and remain valid for the duration of -/// the call. +/// Pointer args must be valid for the stated sizes; `operator_key_text` +/// must be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must +/// come from `dash_sdk_mnemonic_resolver_create` and remain valid for the +/// duration of the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_service( @@ -392,10 +368,8 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_text: *const c_char, - service_address: *const c_char, - platform_node_id: *const u8, + has_platform_p2p_port: bool, platform_p2p_port: u16, - platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_txid: *mut [u8; 32], @@ -407,21 +381,9 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi check_ptr!(wallet_id); check_ptr!(pro_tx_hash); check_ptr!(operator_key_text); - check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); - let key_text = unwrap_result_or_return!(CStr::from_ptr(operator_key_text).to_str()); - let params = match marshal_params( - pro_tx_hash, - service_address, - platform_node_id, - platform_p2p_port, - platform_http_port, - operator_payout_address, - ) { - Ok(params) => params, - Err(e) => return e, - }; + let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(operator_key_text).to_str()); let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, @@ -431,7 +393,16 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi Ok(secret) => secret, Err(e) => return e, }; - run_update_service(context, params, secret, mnemonic_resolver_handle, out_txid) + run_update_service( + context, + pro_tx_hash, + secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + mnemonic_resolver_handle, + out_txid, + ) } /// Prepare — but do NOT broadcast — the ProUpServTx that @@ -451,10 +422,9 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_servi /// the TTL backstop reclaims it. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `service_address` must -/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come -/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the -/// duration of the call. +/// Pointer args must be valid for the stated sizes; `mnemonic_resolver_handle` +/// must come from `dash_sdk_mnemonic_resolver_create` and remain valid for +/// the duration of the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_service( @@ -462,10 +432,8 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_index: u32, - service_address: *const c_char, - platform_node_id: *const u8, + has_platform_p2p_port: bool, platform_p2p_port: u16, - platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_transaction_handle: *mut Handle, @@ -476,20 +444,8 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi *out_transaction_handle = 0; check_ptr!(wallet_id); check_ptr!(pro_tx_hash); - check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); - let params = match marshal_params( - pro_tx_hash, - service_address, - platform_node_id, - platform_p2p_port, - platform_http_port, - operator_payout_address, - ) { - Ok(params) => params, - Err(e) => return e, - }; let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, Err(e) => return e, @@ -504,8 +460,11 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi }; run_prepare_update_service( context, - params, + pro_tx_hash, operator_secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, mnemonic_resolver_handle, out_transaction_handle, ) @@ -519,11 +478,10 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi /// those of [`platform_wallet_manager_masternode_prepare_update_service`][]. /// /// # Safety -/// Pointer args must be valid for the stated sizes; `operator_key_text` and -/// `service_address` must be NUL-terminated UTF-8 strings; -/// `mnemonic_resolver_handle` must come from -/// `dash_sdk_mnemonic_resolver_create` and remain valid for the duration of -/// the call. +/// Pointer args must be valid for the stated sizes; `operator_key_text` must +/// be a NUL-terminated UTF-8 string; `mnemonic_resolver_handle` must come +/// from `dash_sdk_mnemonic_resolver_create` and remain valid for the +/// duration of the call. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_update_service( @@ -531,10 +489,8 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_upda wallet_id: *const u8, pro_tx_hash: *const u8, operator_key_text: *const c_char, - service_address: *const c_char, - platform_node_id: *const u8, + has_platform_p2p_port: bool, platform_p2p_port: u16, - platform_http_port: u16, operator_payout_address: *const c_char, mnemonic_resolver_handle: *mut MnemonicResolverHandle, out_transaction_handle: *mut Handle, @@ -546,21 +502,9 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_upda check_ptr!(wallet_id); check_ptr!(pro_tx_hash); check_ptr!(operator_key_text); - check_ptr!(service_address); check_ptr!(mnemonic_resolver_handle); - let key_text = unwrap_result_or_return!(CStr::from_ptr(operator_key_text).to_str()); - let params = match marshal_params( - pro_tx_hash, - service_address, - platform_node_id, - platform_p2p_port, - platform_http_port, - operator_payout_address, - ) { - Ok(params) => params, - Err(e) => return e, - }; + let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(operator_key_text).to_str()); let context = match resolve_context(manager_handle, wallet_id) { Ok(context) => context, Err(e) => return e, @@ -571,138 +515,28 @@ pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_upda }; run_prepare_update_service( context, - params, + pro_tx_hash, secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, mnemonic_resolver_handle, out_transaction_handle, ) } -/// What the synced masternode list shows for a masternode's service, for -/// the host to prefill its update-service form. Returned by -/// [`platform_wallet_manager_masternode_update_service_suggestion`]. -/// -/// A hint only: the host shows these values and has the user confirm or -/// correct them before passing them to an update-service call. The list does not carry the -/// platform P2P port, so an evonode's always has to be entered. -#[repr(C)] -pub struct MasternodeServiceSuggestionFFI { - /// `"ip:port"` of the Core P2P endpoint the list shows: a heap C string, - /// or null when the entry has no plain IP:port (Tor / I2P / domain-only). - /// Free with [`crate::platform_wallet_string_free`]. - pub service_address: *mut c_char, - /// The list shows a high-performance (evonode) entry. - pub is_evonode: bool, - /// Tenderdash node id, gated by `has_platform_node_id` (evonodes only). - pub platform_node_id: [u8; 20], - pub has_platform_node_id: bool, - /// Platform HTTP (DAPI) port, gated by `has_platform_http_port` - /// (evonodes only). - pub platform_http_port: u16, - pub has_platform_http_port: bool, - /// The list shows the masternode PoSe-banned. When it does not, the host - /// may warn that the node looks healthy; updating a healthy node's - /// service is legitimate, so nothing refuses on it. - pub pose_banned: bool, - /// The list shows v3 extended network info, which the update refuses (a - /// version-2 payload would replace the whole endpoint map). - pub has_extended_net_info: bool, -} - -impl MasternodeServiceSuggestionFFI { - fn empty() -> Self { - Self { - service_address: std::ptr::null_mut(), - is_evonode: false, - platform_node_id: [0u8; 20], - has_platform_node_id: false, - platform_http_port: 0, - has_platform_http_port: false, - pose_banned: false, - has_extended_net_info: false, - } - } - - fn from_suggestion(suggestion: &MasternodeServiceSuggestion) -> Self { - Self { - service_address: suggestion - .service_address - .and_then(|address| CString::new(address.to_string()).ok()) - .map(CString::into_raw) - .unwrap_or(std::ptr::null_mut()), - is_evonode: suggestion.is_evonode, - platform_node_id: suggestion.platform_node_id.unwrap_or([0u8; 20]), - has_platform_node_id: suggestion.platform_node_id.is_some(), - platform_http_port: suggestion.platform_http_port.unwrap_or(0), - has_platform_http_port: suggestion.platform_http_port.is_some(), - pose_banned: suggestion.pose_banned, - has_extended_net_info: suggestion.has_extended_net_info, - } - } -} - -/// Read what the synced masternode list shows for `pro_tx_hash` (32 WIRE -/// bytes) as a prefill suggestion for the update-service form. Read-only -/// and local. -/// -/// Errors: `NotFound` when the list has no such masternode; -/// `ErrorMasternodeListUnavailable` before the list has synced. On any -/// error `out_suggestion` is left empty (null `service_address`). -/// -/// # Safety -/// `pro_tx_hash` must point at 32 readable bytes; `out_suggestion` must be -/// writable. Free a non-null `service_address` with -/// [`crate::platform_wallet_string_free`]. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service_suggestion( - manager_handle: Handle, - pro_tx_hash: *const u8, - out_suggestion: *mut MasternodeServiceSuggestionFFI, -) -> PlatformWalletFFIResult { - check_ptr!(out_suggestion); - std::ptr::write(out_suggestion, MasternodeServiceSuggestionFFI::empty()); - check_ptr!(pro_tx_hash); - - let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); - let Some(spv) = - PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| manager.spv_arc()) - else { - return invalid_handle(); - }; - let suggestion = unwrap_result_or_return!(block_on_worker(async move { - masternode_update_service_suggestion(&spv, &target).await - })); - let Some(suggestion) = suggestion else { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::NotFound, - "no masternode with this proTxHash on the masternode list", - ); - }; - std::ptr::write( - out_suggestion, - MasternodeServiceSuggestionFFI::from_suggestion(&suggestion), - ); - PlatformWalletFFIResult::ok() -} - #[cfg(test)] mod tests { use super::*; use crate::platform_wallet_ffi_result_free; - use crate::platform_wallet_string_free; - - fn service(text: &str) -> CString { - CString::new(text).expect("no interior NUL") - } /// Unknown manager handles must come back as `ErrorInvalidHandle` with - /// the out-param still zeroed, mirroring the withdraw pair's contract. + /// the out-param still zeroed — mirroring the withdraw pair's contract. #[test] - fn should_report_unknown_manager_handles_as_invalid() { + fn unknown_handles_are_invalid_handles() { unsafe { let wallet_id = [0u8; 32]; let pro_tx_hash = [0u8; 32]; - let address = service("203.0.113.7:19999"); let mut txid = [0xAAu8; 32]; // A dangling-but-non-null resolver pointer is fine: the handle // lookup fails before the resolver is ever touched. @@ -713,9 +547,7 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), 0, - address.as_ptr(), - std::ptr::null(), - 0, + false, 0, std::ptr::null(), resolver, @@ -727,15 +559,13 @@ mod tests { platform_wallet_ffi_result_free(&mut result); let mut txid = [0xAAu8; 32]; - let key = CString::new("00").unwrap(); + let key = std::ffi::CString::new("00").unwrap(); let result = platform_wallet_manager_tracked_masternode_update_service( Handle::MAX, wallet_id.as_ptr(), pro_tx_hash.as_ptr(), key.as_ptr(), - address.as_ptr(), - std::ptr::null(), - 0, + false, 0, std::ptr::null(), resolver, @@ -752,11 +582,10 @@ mod tests { /// is an invalid-handle error and the out-param is left at the null /// handle, so a host can never broadcast a stale handle after a failure. #[test] - fn should_report_unknown_manager_handles_as_invalid_when_preparing() { + fn prepare_unknown_handles_are_invalid_handles() { unsafe { let wallet_id = [0u8; 32]; let pro_tx_hash = [0u8; 32]; - let address = service("203.0.113.7:19999"); let resolver = std::ptr::dangling_mut::(); let mut transaction_handle: Handle = 7; @@ -765,9 +594,7 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), 0, - address.as_ptr(), - std::ptr::null(), - 0, + false, 0, std::ptr::null(), resolver, @@ -779,15 +606,13 @@ mod tests { platform_wallet_ffi_result_free(&mut result); let mut transaction_handle: Handle = 7; - let key = CString::new("00").unwrap(); + let key = std::ffi::CString::new("00").unwrap(); let result = platform_wallet_manager_tracked_masternode_prepare_update_service( Handle::MAX, wallet_id.as_ptr(), pro_tx_hash.as_ptr(), key.as_ptr(), - address.as_ptr(), - std::ptr::null(), - 0, + false, 0, std::ptr::null(), resolver, @@ -800,15 +625,13 @@ mod tests { } } - /// Null required pointers are rejected before anything else runs, and - /// a valid `out_txid` is still zeroed first, per its contract. The - /// service address is required. + /// Null required pointers are rejected before anything else runs — + /// and a valid `out_txid` is still zeroed first, per its contract. #[test] - fn should_reject_null_pointers() { + fn null_pointers_are_rejected() { unsafe { let wallet_id = [0u8; 32]; let pro_tx_hash = [0u8; 32]; - let address = service("203.0.113.7:19999"); let mut txid = [0xAAu8; 32]; let result = platform_wallet_manager_masternode_update_service( @@ -816,9 +639,7 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), 0, - address.as_ptr(), - std::ptr::null(), - 0, + false, 0, std::ptr::null(), std::ptr::null_mut(), @@ -838,9 +659,7 @@ mod tests { wallet_id.as_ptr(), pro_tx_hash.as_ptr(), std::ptr::null(), - address.as_ptr(), - std::ptr::null(), - 0, + false, 0, std::ptr::null(), std::ptr::dangling_mut::(), @@ -853,183 +672,6 @@ mod tests { ); let mut result = result; platform_wallet_ffi_result_free(&mut result); - - let mut transaction_handle: Handle = 7; - let result = platform_wallet_manager_masternode_prepare_update_service( - Handle::MAX, - wallet_id.as_ptr(), - pro_tx_hash.as_ptr(), - 0, - std::ptr::null(), - std::ptr::null(), - 0, - 0, - std::ptr::null(), - std::ptr::dangling_mut::(), - &mut transaction_handle, - ); - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); - assert_eq!(transaction_handle, 0, "the service address is required"); - let mut result = result; - platform_wallet_ffi_result_free(&mut result); - } - } - - /// Malformed service input fails as an invalid parameter before the - /// manager handle, the wallet or the resolver is touched. - #[test] - fn should_refuse_malformed_service_input_before_resolving_anything() { - unsafe { - let wallet_id = [0u8; 32]; - let pro_tx_hash = [0u8; 32]; - let resolver = std::ptr::dangling_mut::(); - let node_id = [0x5Au8; 20]; - let cases: [(&str, *const u8, u16, u16); 3] = [ - ("203.0.113.7", std::ptr::null(), 0, 0), - ("203.0.113.7:19999", std::ptr::null(), 36656, 0), - ("not an address", node_id.as_ptr(), 36656, 1443), - ]; - for (text, node_id, p2p, http) in cases { - let address = service(text); - let mut txid = [0xAAu8; 32]; - let result = platform_wallet_manager_masternode_update_service( - Handle::MAX, - wallet_id.as_ptr(), - pro_tx_hash.as_ptr(), - 0, - address.as_ptr(), - node_id, - p2p, - http, - std::ptr::null(), - resolver, - &mut txid, - ); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorInvalidParameter, - "{text} with p2p {p2p} http {http}" - ); - assert_eq!(txid, [0u8; 32]); - let mut result = result; - platform_wallet_ffi_result_free(&mut result); - } } } - - /// A null node id is a regular masternode; 20 bytes are an evonode with - /// the given platform ports. - #[test] - fn should_marshal_the_service_shape_from_the_platform_node_id() { - unsafe { - let pro_tx_hash = [0x11u8; 32]; - let address = service("203.0.113.7:19999"); - - let regular = marshal_params( - pro_tx_hash.as_ptr(), - address.as_ptr(), - std::ptr::null(), - 0, - 0, - std::ptr::null(), - ) - .unwrap_or_else(|_| panic!("a regular service marshals")); - assert_eq!(regular.pro_tx_hash, pro_tx_hash); - assert_eq!( - regular.service, - ConfirmedMasternodeService::Regular { - service_address: "203.0.113.7:19999".parse().unwrap(), - } - ); - assert_eq!(regular.operator_payout_address, None); - - let node_id = [0x5Au8; 20]; - let evonode = marshal_params( - pro_tx_hash.as_ptr(), - address.as_ptr(), - node_id.as_ptr(), - 36656, - 1443, - std::ptr::null(), - ) - .unwrap_or_else(|_| panic!("an evonode service marshals")); - assert_eq!( - evonode.service, - ConfirmedMasternodeService::Evonode { - service_address: "203.0.113.7:19999".parse().unwrap(), - platform_node_id: node_id, - platform_p2p_port: 36656, - platform_http_port: 1443, - } - ); - } - } - - #[test] - fn should_marshal_the_suggestion_fields() { - let suggestion = MasternodeServiceSuggestion { - service_address: Some("1.2.3.4:9999".parse().unwrap()), - is_evonode: true, - platform_node_id: Some([4u8; 20]), - platform_http_port: Some(443), - pose_banned: true, - has_extended_net_info: false, - }; - let ffi = MasternodeServiceSuggestionFFI::from_suggestion(&suggestion); - assert_eq!( - unsafe { CStr::from_ptr(ffi.service_address) } - .to_str() - .unwrap(), - "1.2.3.4:9999" - ); - assert!(ffi.is_evonode); - assert!(ffi.has_platform_node_id); - assert_eq!(ffi.platform_node_id, [4u8; 20]); - assert!(ffi.has_platform_http_port); - assert_eq!(ffi.platform_http_port, 443); - assert!(ffi.pose_banned); - assert!(!ffi.has_extended_net_info); - unsafe { platform_wallet_string_free(ffi.service_address) }; - - let without_address = - MasternodeServiceSuggestionFFI::from_suggestion(&MasternodeServiceSuggestion { - service_address: None, - is_evonode: false, - platform_node_id: None, - platform_http_port: None, - pose_banned: false, - has_extended_net_info: false, - }); - assert!(without_address.service_address.is_null()); - assert!(!without_address.has_platform_node_id); - assert!(!without_address.has_platform_http_port); - } - - #[test] - fn should_report_an_unknown_manager_handle_when_suggesting() { - let pro_tx_hash = [0u8; 32]; - let mut out = MasternodeServiceSuggestionFFI::empty(); - out.pose_banned = true; - let mut result = unsafe { - platform_wallet_manager_masternode_update_service_suggestion( - Handle::MAX, - pro_tx_hash.as_ptr(), - &mut out, - ) - }; - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); - assert!(out.service_address.is_null()); - assert!(!out.pose_banned, "the out-param is reset on every path"); - unsafe { platform_wallet_ffi_result_free(&mut result) }; - - let mut result = unsafe { - platform_wallet_manager_masternode_update_service_suggestion( - Handle::MAX, - std::ptr::null(), - &mut out, - ) - }; - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); - unsafe { platform_wallet_ffi_result_free(&mut result) }; - } } diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index ea52bd291e1..6c86b526105 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -31,8 +31,7 @@ pub use tracked::{ PlatformKeySnapshot, RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot, }; pub use update_service::{ - execute_masternode_update_service, masternode_update_service_suggestion, parse_service_address, - prepare_masternode_update_service, ConfirmedMasternodeService, MasternodeServiceSuggestion, + execute_masternode_update_service, prepare_masternode_update_service, MasternodeUpdateServiceParams, }; diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 6a9f9ce98b6..0d12b781ce5 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -1,22 +1,19 @@ //! ProUpServTx (provider update service) orchestration. //! -//! Asserts a masternode's or evonode's service values in a ProUpServTx signed -//! with the operator BLS key, which also revives the node if it is -//! PoSe-banned. The payload commits to the funding inputs (`inputs_hash`) and -//! each funding input's ECDSA sighash covers the finished payload, so the -//! build order is fixed: select and reserve inputs → write `inputs_hash` → -//! BLS-sign the payload → ECDSA-sign the inputs → broadcast. key-wallet's +//! Revives a PoSe-banned masternode or evonode by re-asserting its current +//! service values in a ProUpServTx signed with the operator BLS key. The +//! payload commits to the funding inputs (`inputs_hash`) and each funding +//! input's ECDSA sighash covers the finished payload, so the build order is +//! fixed: select and reserve inputs → write `inputs_hash` → BLS-sign the +//! payload → ECDSA-sign the inputs → broadcast. key-wallet's //! `TransactionBuilder::set_payload_finalizer` is the seam that makes steps //! two and three possible between selection and input signing. //! -//! The service values are an explicit input the caller confirms -//! ([`ConfirmedMasternodeService`]), and the payload is built from that input -//! alone. [`masternode_update_service_suggestion`] reads what the synced -//! masternode list shows so a host can prefill its form; that is a hint the -//! user confirms, never a payload source. The confirmed masternode type is -//! checked against the registration transaction, and the payout script -//! follows a hard rule (see [`resolve_operator_payout_script`]) so an update -//! can never silently clear an operator payout on-chain. +//! This is deliberately revive-only: every payload field except the payout +//! script is copied verbatim from the live masternode-list entry, and the +//! payout script follows a hard rule (see +//! [`resolve_operator_payout_script`]) so an unban can never silently clear +//! an operator payout on-chain. use dashcore::blockdata::script::ScriptBuf; use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; @@ -44,16 +41,18 @@ use crate::spv::SpvRuntime; use crate::wallet::core::{CoreWallet, SignedCoreTransaction, SEND_FUNDING_SOURCES}; use crate::wallet::platform_wallet::PlatformWallet; -/// What an update-service request asserts and chooses. Everything the -/// payload carries comes from here; nothing is copied from the synced -/// masternode list. +/// What an update-service (unban) request lets the caller choose. Everything +/// else — service address, masternode type, platform node id and HTTP port — +/// is copied from the live masternode-list entry. #[derive(Debug, Clone)] pub struct MasternodeUpdateServiceParams { /// ProRegTx hash of the masternode to update, in WIRE order (the same /// order `MasternodeListSummary::pro_tx_hash` uses). pub pro_tx_hash: [u8; 32], - /// The service values the payload asserts, as the caller confirmed them. - pub service: ConfirmedMasternodeService, + /// Platform P2P port for an evonode payload. The masternode list does + /// not carry it, so the caller must supply it for an evonode; it must be + /// `None` for a regular masternode. + pub platform_p2p_port: Option, /// Operator payout address. Consensus REPLACES the current operator /// payout script with this payload's, and an empty script clears it — /// see [`resolve_operator_payout_script`] for the rule that keeps that @@ -61,136 +60,10 @@ pub struct MasternodeUpdateServiceParams { pub operator_payout_address: Option, } -/// The service values a ProUpServTx asserts, as the caller confirmed them. -/// -/// Hosts may prefill these from [`masternode_update_service_suggestion`], -/// but the operator is the one asserting them, so every value must be shown -/// to the user and confirmed (or corrected) before it is passed in. The -/// variant must match how the masternode was registered; the update refuses -/// a mismatch against the registration transaction. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfirmedMasternodeService { - /// A regular masternode: the Core P2P endpoint only. - Regular { - /// Core P2P endpoint. The version-2 payload carries an IPv4 address. - service_address: SocketAddr, - }, - /// An evonode: the Core P2P endpoint plus the platform fields the - /// payload serializes for the high-performance type. - Evonode { - /// Core P2P endpoint. The version-2 payload carries an IPv4 address. - service_address: SocketAddr, - /// Tenderdash node id (`SHA256(ed25519 pk)[..20]`). - platform_node_id: [u8; 20], - /// Tenderdash P2P port. - platform_p2p_port: u16, - /// Platform HTTP (DAPI) port. - platform_http_port: u16, - }, -} - -impl ConfirmedMasternodeService { - /// The Core P2P endpoint, whichever the type. - pub fn service_address(&self) -> SocketAddr { - match self { - Self::Regular { service_address } - | Self::Evonode { - service_address, .. - } => *service_address, - } - } - - /// Whether this is the evonode (high-performance) shape. - pub fn is_evonode(&self) -> bool { - matches!(self, Self::Evonode { .. }) - } -} - -/// What the synced masternode list currently shows for a masternode's -/// service, for a host to prefill its update-service form. -/// -/// A hint only. The host must show these values and have the user confirm -/// or correct them; the confirmed values, not these, become the -/// [`ConfirmedMasternodeService`] the payload is built from. The list does -/// not carry the platform P2P port, so an evonode's always has to be -/// entered. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MasternodeServiceSuggestion { - /// Primary Core P2P endpoint. `None` for Tor / I2P / CJDNS / - /// domain-only entries, which have no IP:port form. - pub service_address: Option, - /// The list shows a high-performance (evonode) entry. - pub is_evonode: bool, - /// Tenderdash node id, evonodes only. - pub platform_node_id: Option<[u8; 20]>, - /// Platform HTTP (DAPI) port, evonodes only. - pub platform_http_port: Option, - /// The list shows the masternode PoSe-banned. When it does not, a host - /// may say so before the user sends: updating a healthy node's service - /// is legitimate, so nothing refuses on it. - pub pose_banned: bool, - /// The list shows v3 extended network info. The update refuses such an - /// entry, since a version-2 payload would replace its whole endpoint map - /// with one address, so a host can explain that before the user fills - /// in the form. - pub has_extended_net_info: bool, -} - -impl MasternodeServiceSuggestion { - /// The suggestion for one list entry. - pub fn from_summary(entry: &MasternodeListSummary) -> Self { - Self { - service_address: entry.service_address, - is_evonode: entry.is_evonode, - platform_node_id: entry.platform_node_id, - platform_http_port: entry.platform_http_port, - pose_banned: !entry.is_valid, - has_extended_net_info: entry.has_extended_net_info, - } - } -} - -/// What the synced masternode list shows for `pro_tx_hash` (wire order), as -/// a [`MasternodeServiceSuggestion`] a host prefills its form with and the -/// user then confirms. `None` when the list has no such masternode; errors -/// with [`PlatformWalletError::MasternodeListUnavailable`] before the list -/// has synced. Read-only and local. -pub async fn masternode_update_service_suggestion( - spv: &SpvRuntime, - pro_tx_hash: &[u8; 32], -) -> Result, PlatformWalletError> { - let summaries = spv - .masternode_list_summaries() - .await - .ok_or(PlatformWalletError::MasternodeListUnavailable)?; - Ok(summaries - .iter() - .find(|entry| &entry.pro_tx_hash == pro_tx_hash) - .map(MasternodeServiceSuggestion::from_summary)) -} - -/// Parse a host-entered `"a.b.c.d:port"` service address. The port is -/// required: the Core P2P port differs between mainnet and the test -/// networks, so it is never guessed. -pub fn parse_service_address(text: &str) -> Result { - let text = text.trim(); - text.parse::().map_err(|_| { - if text.parse::().is_ok() { - PlatformWalletError::InvalidParameter(format!( - "the service address {text} needs a port, as IP:port" - )) - } else { - PlatformWalletError::InvalidParameter(format!( - "{text:?} is not an IP:port service address" - )) - } - }) -} - /// Build, operator-BLS-sign, fund, input-sign, and broadcast a ProUpServTx -/// asserting `params.service` for `params.pro_tx_hash` (the transaction -/// Core produces for `protx update_service`), which also revives the -/// masternode if it is PoSe-banned. +/// that re-asserts `pro_tx_hash`'s current service values — the transaction +/// Core produces for `protx update_service` — which revives the masternode +/// if it is PoSe-banned. /// /// `operator_secret` is the operator's BLS12-381 secret scalar in big-endian /// bytes (the same convention as [`bls_public_keys`]); it is verified against @@ -226,10 +99,6 @@ pub async fn execute_masternode_update_service( wallet: &PlatformWallet, spv: &SpvRuntime, @@ -237,8 +106,6 @@ pub async fn prepare_masternode_update_service, signer: &S, ) -> Result { - validate_confirmed_service(¶ms.service)?; - let summaries = spv .masternode_list_summaries() .await @@ -252,59 +119,29 @@ pub async fn prepare_masternode_update_service Result<(), PlatformWalletError> { - verify_operator_secret(&entry.operator_public_key, operator_secret)?; - if entry.has_extended_net_info { - return Err(PlatformWalletError::InvalidParameter( - "this masternode advertises v3 extended network info; a version-2 update-service \ - payload would replace its whole endpoint map with a single address, so it cannot \ - be updated from this wallet yet" - .to_string(), - )); - } - Ok(()) -} + let placeholder = + prepare_update_service_placeholder(entry, params.platform_p2p_port, script_payout)?; -/// What the ProRegTx fixed for the masternode's lifetime, read from the -/// fetched registration transaction once it is bound to the requested -/// proTxHash. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct RegistrationTerms { - /// `operatorReward` in basis points. - pub(crate) operator_reward: u16, - /// Regular or evonode. No transaction changes it after registration, - /// and a ProUpServTx must carry the same type. - pub(crate) masternode_type: ProviderMasternodeType, + build_sign_update_service(wallet.core(), placeholder, operator_secret, signer).await } -/// The [`RegistrationTerms`] of `pro_tx_hash`, from its ProRegTx fetched via -/// DAPI Core. Fails closed when the transaction cannot be fetched: neither -/// the payout rule nor the type check can be applied without it. -async fn fetch_registration_terms( +/// The `operatorReward` (basis points) the masternode was registered with, +/// read from its ProRegTx via DAPI Core. Fails closed when the transaction +/// cannot be fetched — the payout rule cannot be applied without it. +async fn fetch_operator_reward( wallet: &PlatformWallet, pro_tx_hash: &[u8; 32], -) -> Result { +) -> Result { let display = display_hex(pro_tx_hash); let fetched = wallet .sdk() @@ -321,19 +158,21 @@ async fn fetch_registration_terms( operator reward" )) })?; - registration_terms(pro_tx_hash, &fetched.transaction) + operator_reward_from_registration(pro_tx_hash, &fetched.transaction) } -/// Read the [`RegistrationTerms`] out of a fetched registration transaction, +/// Read `operatorReward` out of a fetched registration transaction, /// binding the response to the request first: DAPI's get-transaction reply /// is not authenticated, so the decoded transaction must hash to the -/// requested proTxHash before its payload is trusted. Without this check a +/// requested proTxHash before its payload is trusted. Only the operator +/// reward is read from it; the service values come from the synced +/// masternode list's entry for that proTxHash. Without the hash check a /// reply carrying an unrelated zero-reward ProRegTx would steer /// [`resolve_operator_payout_script`] into clearing a real operator payout. -pub(crate) fn registration_terms( +pub(crate) fn operator_reward_from_registration( pro_tx_hash: &[u8; 32], - transaction: &Transaction, -) -> Result { + transaction: &dashcore::Transaction, +) -> Result { let expected = Txid::from_byte_array(*pro_tx_hash); let actual = transaction.txid(); if actual != expected { @@ -344,10 +183,7 @@ pub(crate) fn registration_terms( } match &transaction.special_transaction_payload { Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => { - Ok(RegistrationTerms { - operator_reward: registration.operator_reward, - masternode_type: registration.masternode_type, - }) + Ok(registration.operator_reward) } _ => Err(PlatformWalletError::InvalidParameter(format!( "transaction {expected} is not a provider registration transaction" @@ -420,143 +256,80 @@ pub(crate) fn verify_operator_secret( Ok(()) } -/// Refuse confirmed service values no ProUpServTx can carry, before any -/// network work. Core validates the rest (routability, each network's -/// required ports, uniqueness across the list) when the transaction -/// arrives; these are the checks a mistyped value most often fails, and -/// catching them here keeps a transaction the network would refuse from -/// being built at all. -pub(crate) fn validate_confirmed_service( - service: &ConfirmedMasternodeService, -) -> Result<(), PlatformWalletError> { - let address = service.service_address(); - let ipv4 = match address.ip() { - IpAddr::V4(v4) => Some(v4), - IpAddr::V6(v6) => v6.to_ipv4_mapped(), - }; - let Some(ipv4) = ipv4 else { - return Err(PlatformWalletError::InvalidParameter(format!( - "the service address {address} is not IPv4; a version-2 update-service payload \ - carries an IPv4 address" - ))); - }; - if ipv4.is_unspecified() || address.port() == 0 { - return Err(PlatformWalletError::InvalidParameter(format!( - "the service address {address} needs a real IP and port" - ))); - } - if let ConfirmedMasternodeService::Evonode { - platform_node_id, - platform_p2p_port, - platform_http_port, - .. - } = service - { - if platform_node_id == &[0u8; 20] { - return Err(PlatformWalletError::InvalidParameter( - "an evonode payload needs the platform node id".to_string(), - )); - } - if *platform_p2p_port == 0 || *platform_http_port == 0 { - return Err(PlatformWalletError::InvalidParameter( - "an evonode payload needs both the platform P2P port and the platform HTTP port" - .to_string(), - )); - } - if platform_p2p_port == platform_http_port - || *platform_p2p_port == address.port() - || *platform_http_port == address.port() - { - return Err(PlatformWalletError::InvalidParameter( - "the Core P2P port, platform P2P port and platform HTTP port must all differ" - .to_string(), - )); - } - } - Ok(()) -} - -/// Refuse a confirmed service whose type differs from the registration's. -/// The type is fixed at registration and a ProUpServTx must repeat it, so -/// the registration transaction (bound to the proTxHash) decides. -pub(crate) fn check_registered_type( - registered: ProviderMasternodeType, - service: &ConfirmedMasternodeService, -) -> Result<(), PlatformWalletError> { - match (registered, service.is_evonode()) { - (ProviderMasternodeType::Regular, false) - | (ProviderMasternodeType::HighPerformance, true) => Ok(()), - (ProviderMasternodeType::HighPerformance, false) => { - Err(PlatformWalletError::InvalidParameter( - "this masternode is registered as an evonode; confirm its platform node id, \ - platform P2P port and platform HTTP port" - .to_string(), - )) - } - (ProviderMasternodeType::Regular, true) => Err(PlatformWalletError::InvalidParameter( - "this masternode is registered as a regular masternode, so it has no platform \ - fields to update" - .to_string(), - )), - } -} - -/// The placeholder payload for `params`, checked against the registration: -/// the confirmed service values, the registered type, and the payout script -/// the payout rule resolves. Nothing here reads the masternode list. -pub(crate) fn update_service_placeholder( - registration: &RegistrationTerms, - params: &MasternodeUpdateServiceParams, - network: Network, -) -> Result { - check_registered_type(registration.masternode_type, ¶ms.service)?; - let script_payout = resolve_operator_payout_script( - registration.operator_reward, - params.operator_payout_address.as_deref(), - network, - )?; - prepare_update_service_placeholder(params.pro_tx_hash, ¶ms.service, script_payout) -} - -/// The placeholder payload for the builder, built from the confirmed -/// service values alone: every field final except the two +/// The placeholder payload for the builder: every field final except the two /// selection-dependent ones (`inputs_hash`, `payload_sig`), which are zeroed /// and filled by the payload finalizer after input selection. Always version /// 2 (BasicBLS) — every current network is past the v19 hard fork. pub(crate) fn prepare_update_service_placeholder( - pro_tx_hash: [u8; 32], - service: &ConfirmedMasternodeService, + entry: &MasternodeListSummary, + platform_p2p_port: Option, script_payout: ScriptBuf, ) -> Result { - validate_confirmed_service(service)?; - let (ip_address, port) = service_payload_fields(service.service_address()); + // A v3 extended entry advertises an endpoint map the version-2 payload + // cannot express: Core would replace the whole map with the single + // address below, downgrading the entry and discarding live endpoints. + // Refuse until a v3 ProUpServTx payload exists to re-assert it. + if entry.has_extended_net_info { + return Err(PlatformWalletError::InvalidParameter( + "this masternode advertises v3 extended network info; a version-2 update-service \ + payload would replace its whole endpoint map with a single address, so it cannot \ + be re-asserted from this wallet yet" + .to_string(), + )); + } + let service = entry.service_address.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "the masternode's service address is not a plain IP:port entry, so it cannot be \ + re-asserted from the masternode list" + .to_string(), + ) + })?; + let (ip_address, port) = service_payload_fields(service); // The platform triplet is serialized only when mn_type is HighPerformance, // so the evonode/regular split must be explicit here — a missing mn_type // would silently drop the platform fields on the wire. - let (mn_type, platform_node_id, platform_p2p_port, platform_http_port) = match service { - ConfirmedMasternodeService::Evonode { - platform_node_id, - platform_p2p_port, - platform_http_port, - .. - } => ( + let (mn_type, platform_node_id, platform_p2p_port, platform_http_port) = if entry.is_evonode { + let node_id = entry.platform_node_id.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "the masternode list entry is an evonode without a platform node id".to_string(), + ) + })?; + let http_port = entry.platform_http_port.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "the masternode list entry is an evonode without a platform HTTP port".to_string(), + ) + })?; + let p2p_port = platform_p2p_port.ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "an evonode payload requires the platform P2P port (the masternode list does \ + not carry it)" + .to_string(), + ) + })?; + ( Some(ProviderMasternodeType::HighPerformance as u16), - Some(PlatformNodeId::from_byte_array(*platform_node_id)), - Some(*platform_p2p_port), - Some(*platform_http_port), - ), - ConfirmedMasternodeService::Regular { .. } => ( + Some(PlatformNodeId::from_byte_array(node_id)), + Some(p2p_port), + Some(http_port), + ) + } else { + if platform_p2p_port.is_some() { + return Err(PlatformWalletError::InvalidParameter( + "a platform P2P port was given, but this masternode is not an evonode".to_string(), + )); + } + ( Some(ProviderMasternodeType::Regular as u16), None, None, None, - ), + ) }; Ok(ProviderUpdateServicePayload::new( mn_type, - Txid::from_byte_array(pro_tx_hash), + Txid::from_byte_array(entry.pro_tx_hash), ip_address, port, script_payout, @@ -689,7 +462,9 @@ mod tests { use super::super::list::test_support::{evonode, masternode}; use super::*; use crate::broadcaster::BroadcastError; - use crate::test_support::{funded_wallet_manager, funded_wallet_manager_with_outputs}; + use crate::test_support::{ + funded_wallet_manager, funded_wallet_manager_with_outputs, WalletSigner, + }; use dashcore::blsful::{PublicKey as BlsPublicKey, Signature as BlsSignature}; use key_wallet::account::StandardAccountType; use std::sync::{Arc, Mutex}; @@ -705,43 +480,6 @@ mod tests { entry } - /// A confirmed regular service at a documentation address, distinct - /// from every `masternode(seed)` list fixture (`10.0.0.:9999`). - fn confirmed_regular() -> ConfirmedMasternodeService { - ConfirmedMasternodeService::Regular { - service_address: "203.0.113.7:19999".parse().expect("socket address"), - } - } - - /// A confirmed evonode service whose every field differs from the - /// `evonode(seed)` list fixture (node id `seed ^ 0xFF`, HTTP port 443). - fn confirmed_evonode() -> ConfirmedMasternodeService { - ConfirmedMasternodeService::Evonode { - service_address: "203.0.113.8:19999".parse().expect("socket address"), - platform_node_id: [0x5A; 20], - platform_p2p_port: 36656, - platform_http_port: 1443, - } - } - - fn params( - pro_tx_hash: [u8; 32], - service: ConfirmedMasternodeService, - ) -> MasternodeUpdateServiceParams { - MasternodeUpdateServiceParams { - pro_tx_hash, - service, - operator_payout_address: None, - } - } - - fn registered(masternode_type: ProviderMasternodeType) -> RegistrationTerms { - RegistrationTerms { - operator_reward: 0, - masternode_type, - } - } - #[derive(Default)] struct RecordingBroadcaster { sent: Mutex>, @@ -771,7 +509,7 @@ mod tests { ) -> ( CoreWallet, Arc, - crate::test_support::WalletSigner, + WalletSigner, ) { let (wallet_manager, wallet_id, generation, signer) = funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, outputs).await; @@ -788,8 +526,8 @@ mod tests { } fn regular_placeholder() -> ProviderUpdateServicePayload { - prepare_update_service_placeholder([0x44; 32], &confirmed_regular(), ScriptBuf::new()) - .expect("placeholder") + let entry = operator_entry(0x44, false); + prepare_update_service_placeholder(&entry, None, ScriptBuf::new()).expect("placeholder") } /// The IPv4-mapped little-endian encoding, pinned against the known @@ -854,38 +592,11 @@ mod tests { assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } - /// The list entry sits at `10.0.0.17:9999`; the caller confirmed - /// `203.0.113.7:19999`. The list preflights pass and the payload carries - /// the confirmed address, because the placeholder never reads the list. #[test] - fn should_build_the_payload_from_the_confirmed_address_not_the_list_entry() { + fn placeholder_for_a_regular_masternode_omits_the_platform_fields() { let entry = operator_entry(0x11, false); - let listed = entry.service_address.expect("fixture has a plain address"); - let confirmed = confirmed_regular(); - assert_ne!( - listed, - confirmed.service_address(), - "fixture addresses differ" - ); - - check_list_entry(&entry, &OPERATOR_SECRET).expect("list preflights pass"); - let payload = update_service_placeholder( - ®istered(ProviderMasternodeType::Regular), - ¶ms(entry.pro_tx_hash, confirmed), - Network::Testnet, - ) - .expect("regular placeholder"); - - assert_eq!( - (payload.ip_address, payload.port), - service_payload_fields(confirmed.service_address()), - "the payload asserts the confirmed address" - ); - assert_ne!( - (payload.ip_address, payload.port), - service_payload_fields(listed), - "the list entry's address never reaches the payload" - ); + let payload = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + .expect("regular placeholder"); assert_eq!( payload.version, ProviderUpdateServicePayload::CURRENT_VERSION @@ -903,170 +614,48 @@ mod tests { assert_eq!(payload.platform_http_port, None); assert_eq!(payload.inputs_hash, InputsHash::all_zeros()); assert_eq!(payload.payload_sig, BLSSignature::from([0u8; 96])); + + let err = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) + .expect_err("a platform P2P port on a regular masternode must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } - /// Every evonode field comes from the confirmed input: the list - /// fixture's node id and HTTP port differ from the confirmed ones. #[test] - fn should_take_every_evonode_field_from_the_confirmed_input() { + fn placeholder_for_an_evonode_requires_and_carries_the_platform_triplet() { let entry = operator_entry(0x22, true); - let ConfirmedMasternodeService::Evonode { - service_address, - platform_node_id, - platform_p2p_port, - platform_http_port, - } = confirmed_evonode() - else { - panic!("confirmed_evonode is the evonode shape"); - }; - assert_ne!(entry.platform_node_id, Some(platform_node_id)); - assert_ne!(entry.platform_http_port, Some(platform_http_port)); - - check_list_entry(&entry, &OPERATOR_SECRET).expect("list preflights pass"); - let payload = update_service_placeholder( - ®istered(ProviderMasternodeType::HighPerformance), - ¶ms(entry.pro_tx_hash, confirmed_evonode()), - Network::Testnet, - ) - .expect("evonode placeholder"); - + let payload = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) + .expect("evonode placeholder"); assert_eq!( payload.mn_type, Some(ProviderMasternodeType::HighPerformance as u16) ); - assert_eq!( - (payload.ip_address, payload.port), - service_payload_fields(service_address) - ); assert_eq!( payload.platform_node_id, - Some(PlatformNodeId::from_byte_array(platform_node_id)) + entry.platform_node_id.map(PlatformNodeId::from_byte_array) ); - assert_eq!(payload.platform_p2p_port, Some(platform_p2p_port)); - assert_eq!(payload.platform_http_port, Some(platform_http_port)); - } - - /// The registration fixes the type, so a confirmed service of the other - /// shape is refused before any payload exists. - #[test] - fn should_refuse_a_service_type_that_differs_from_the_registration() { - let err = update_service_placeholder( - ®istered(ProviderMasternodeType::HighPerformance), - ¶ms([0x33; 32], confirmed_regular()), - Network::Testnet, - ) - .expect_err("a registered evonode needs its platform fields"); - assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + assert_eq!(payload.platform_p2p_port, Some(26656)); + assert_eq!(payload.platform_http_port, entry.platform_http_port); - let err = update_service_placeholder( - ®istered(ProviderMasternodeType::Regular), - ¶ms([0x33; 32], confirmed_evonode()), - Network::Testnet, - ) - .expect_err("a registered regular masternode has no platform fields"); + let err = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + .expect_err("an evonode payload without the P2P port must be refused"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } #[test] - fn should_refuse_confirmed_service_values_no_payload_can_carry() { - let regular = |address: &str| ConfirmedMasternodeService::Regular { - service_address: address.parse().expect("socket address"), - }; - for address in ["[2001:db8::1]:19999", "0.0.0.0:19999", "203.0.113.7:0"] { - let err = validate_confirmed_service(®ular(address)) - .expect_err("an address no version-2 payload can carry"); - assert!( - matches!(err, PlatformWalletError::InvalidParameter(_)), - "{address}" - ); - } - validate_confirmed_service(®ular("[::ffff:203.0.113.7]:19999")) - .expect("an IPv4-mapped address is IPv4"); - - let evonode = - |node_id: [u8; 20], p2p: u16, http: u16| ConfirmedMasternodeService::Evonode { - service_address: "203.0.113.8:19999".parse().expect("socket address"), - platform_node_id: node_id, - platform_p2p_port: p2p, - platform_http_port: http, - }; - for (label, service) in [ - ("empty node id", evonode([0; 20], 36656, 1443)), - ("no P2P port", evonode([1; 20], 0, 1443)), - ("no HTTP port", evonode([1; 20], 36656, 0)), - ("P2P equals HTTP", evonode([1; 20], 1443, 1443)), - ("P2P equals Core", evonode([1; 20], 19999, 1443)), - ("HTTP equals Core", evonode([1; 20], 36656, 19999)), - ] { - let err = validate_confirmed_service(&service).expect_err(label); - assert!( - matches!(err, PlatformWalletError::InvalidParameter(_)), - "{label}" - ); - } - validate_confirmed_service(&confirmed_evonode()).expect("a complete evonode service"); - } - - #[test] - fn should_parse_a_service_address_only_with_a_port() { - assert_eq!( - parse_service_address(" 203.0.113.7:19999 ").expect("IP:port"), - "203.0.113.7:19999" - .parse::() - .expect("socket address") - ); - for text in ["203.0.113.7", "node.example:19999", ""] { - let err = parse_service_address(text).expect_err(text); - assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); - } - } - - /// A v2 payload would replace the whole endpoint map with one address, - /// so the list's extended-net-info flag refuses even though a routable - /// primary exists. - #[test] - fn should_refuse_a_list_entry_with_extended_net_info() { + fn placeholder_refuses_a_v3_extended_net_info_entry() { + // A v2 payload would replace the whole endpoint map with the primary + // address — refuse even though a routable primary exists. let mut entry = operator_entry(0x55, false); entry.has_extended_net_info = true; assert!( entry.service_address.is_some(), "primary address present and routable" ); - let err = check_list_entry(&entry, &OPERATOR_SECRET) + let err = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) .expect_err("an extended-net-info entry must be refused"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } - /// The caller supplies the address, so a list entry without a plain - /// IP:port (a Tor-only entry, say) no longer blocks the update. - #[test] - fn should_accept_a_list_entry_without_a_plain_service_address() { - let mut entry = operator_entry(0x33, false); - entry.service_address = None; - check_list_entry(&entry, &OPERATOR_SECRET).expect("the list address is not needed"); - } - - #[test] - fn should_suggest_the_list_entry_with_its_banned_status() { - let mut entry = evonode(0x66); - entry.is_valid = false; - let suggestion = MasternodeServiceSuggestion::from_summary(&entry); - assert_eq!(suggestion.service_address, entry.service_address); - assert!(suggestion.is_evonode); - assert_eq!(suggestion.platform_node_id, entry.platform_node_id); - assert_eq!(suggestion.platform_http_port, entry.platform_http_port); - assert!(suggestion.pose_banned); - assert!(!suggestion.has_extended_net_info); - - let healthy = MasternodeServiceSuggestion::from_summary(&masternode(0x67)); - assert!( - !healthy.pose_banned, - "a valid entry is reported as not banned" - ); - assert!(!healthy.is_evonode); - assert_eq!(healthy.platform_node_id, None); - } - fn registration_transaction(operator_reward: u16) -> Transaction { use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderRegistrationPayload; use dashcore::bls_sig_utils::BLSPublicKey; @@ -1106,21 +695,15 @@ mod tests { /// trusted only after the decoded transaction hashes to the requested /// proTxHash. #[test] - fn should_bind_the_registration_to_the_requested_pro_tx_hash() { + fn operator_reward_binds_the_fetched_transaction_to_the_request() { let transaction = registration_transaction(500); let matching = transaction.txid().to_byte_array(); - let terms = registration_terms(&matching, &transaction) + let reward = operator_reward_from_registration(&matching, &transaction) .expect("a matching registration transaction is accepted"); - assert_eq!( - terms, - RegistrationTerms { - operator_reward: 500, - masternode_type: ProviderMasternodeType::Regular, - } - ); + assert_eq!(reward, 500); - let err = registration_terms(&[0x99; 32], &transaction) + let err = operator_reward_from_registration(&[0x99; 32], &transaction) .expect_err("a transaction that does not hash to the request must be refused"); assert!(matches!(err, PlatformWalletError::InvalidIdentityData(_))); @@ -1128,13 +711,22 @@ mod tests { let mut not_registration = registration_transaction(0); not_registration.special_transaction_payload = None; let plain_txid = not_registration.txid().to_byte_array(); - let err = registration_terms(&plain_txid, ¬_registration) + let err = operator_reward_from_registration(&plain_txid, ¬_registration) .expect_err("a non-registration transaction must be refused"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + #[test] + fn placeholder_refuses_an_entry_without_a_plain_service_address() { + let mut entry = operator_entry(0x33, false); + entry.service_address = None; + let err = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + .expect_err("no service address to re-assert"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + #[tokio::test] - async fn should_build_sign_and_broadcast_a_pro_up_serv_tx() { + async fn builds_signs_and_broadcasts_a_pro_up_serv_tx() { let (wallet_manager, wallet_id, generation, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); @@ -1147,9 +739,9 @@ mod tests { generation, ); - let placeholder = - prepare_update_service_placeholder([0x44; 32], &confirmed_evonode(), ScriptBuf::new()) - .expect("placeholder"); + let entry = operator_entry(0x44, true); + let placeholder = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) + .expect("placeholder"); let prepared = build_sign_update_service(&core, placeholder, Zeroizing::new(OPERATOR_SECRET), &signer) @@ -1158,7 +750,14 @@ mod tests { // Building signs but must not send: the preview flow shows this exact // transaction before the user decides. - assert_eq!(broadcaster.sent_count(), 0, "preparing must not broadcast"); + assert!( + broadcaster + .sent + .lock() + .expect("broadcaster lock") + .is_empty(), + "preparing must not broadcast" + ); let txid = core .broadcast_finalized_transaction(&prepared) @@ -1193,12 +792,12 @@ mod tests { payload.mn_type, Some(ProviderMasternodeType::HighPerformance as u16) ); - assert_eq!(payload.platform_p2p_port, Some(36656)); - assert_eq!(payload.platform_http_port, Some(1443)); + assert_eq!(payload.platform_p2p_port, Some(26656)); + assert_eq!(payload.platform_http_port, entry.platform_http_port); assert_ne!(payload.payload_sig, BLSSignature::from([0u8; 96])); // The payload signature verifies under the basic scheme against the - // operator public key, over base_payload_hash: the exact convention + // operator public key, over base_payload_hash — the exact convention // `verify_message_digest` checks real mainnet signatures with. let secret = Option::>::from( BlsSecretKey::::from_be_bytes(&OPERATOR_SECRET), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 112145d2c8f..4c2bc2764b5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -358,84 +358,29 @@ extension PlatformWalletManager { }.value } - /// What the synced masternode list shows for a masternode's service, - /// to prefill the update-service form. A hint only: show every value - /// and have the user confirm or correct it before building the - /// `MasternodeServiceConfirmation` an update-service call takes. The - /// list does not carry an evonode's platform P2P port, so that one is - /// always entered. - /// - /// `poseBanned` lets the form warn when the node looks healthy; the - /// update itself never refuses on it. Throws `.notFound` when the list - /// has no such masternode and `.masternodeListUnavailable` before the - /// list has synced. Local; no network. - public func masternodeUpdateServiceSuggestion( - proTxHash: Data - ) async throws -> MasternodeServiceSuggestion { - guard isConfigured, handle != NULL_HANDLE, proTxHash.count == 32 else { - throw PlatformWalletError.invalidParameter( - "Manager not configured, or proTxHash not 32 bytes") - } - - let handle = self.handle - return try await Task.detached(priority: .userInitiated) { () -> MasternodeServiceSuggestion in - var out = MasternodeServiceSuggestionFFI() - let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - platform_wallet_manager_masternode_update_service_suggestion( - handle, - raw.baseAddress?.assumingMemoryBound(to: UInt8.self), - &out - ) - } - // Rust heap-allocates the address string on success; copy it - // into Swift and free it on every path. - defer { - if out.service_address != nil { - platform_wallet_string_free(out.service_address) - } - } - let result = PlatformWalletResult(ffiResult) - guard result.isSuccess else { - throw PlatformWalletError(result: result) - } - return MasternodeServiceSuggestion( - serviceAddress: out.service_address.map { String(cString: $0) }, - isEvonode: out.is_evonode, - platformNodeId: out.has_platform_node_id - ? withUnsafeBytes(of: &out.platform_node_id) { Data($0) } - : nil, - platformHTTPPort: out.has_platform_http_port ? out.platform_http_port : nil, - poseBanned: out.pose_banned, - hasExtendedNetInfo: out.has_extended_net_info - ) - }.value - } - - /// Update service (which also unbans): broadcast a ProUpServTx - /// asserting the service values the user confirmed for this - /// wallet-owned masternode, which revives it if it is PoSe-banned. - /// Pure bridge: the whole orchestration (list lookup, operator-key - /// derive + match, registration check, payout rule, funding, BLS + /// Unban / update-service: broadcast a ProUpServTx re-asserting this + /// wallet-owned masternode's current service values — which revives it + /// if it is PoSe-banned. Pure bridge — the whole orchestration (list + /// lookup, operator-key derive + match, payout rule, funding, BLS /// payload sign, input sign, broadcast) lives in `platform-wallet` /// behind this one FFI call, per CLAUDE.md. /// /// - `operatorKeyIndex`: the wallet's operator-key index for this - /// masternode, the record's `operatorKeyIndex` join field. - /// - `service`: the confirmed service values; the payload carries these, - /// not the masternode list's entry. Prefill them from - /// `masternodeUpdateServiceSuggestion(proTxHash:)`. + /// masternode — the record's `operatorKeyIndex` join field. + /// - `platformP2PPort`: required for an evonode (the masternode list + /// does not carry it); must be nil for a regular masternode. /// - `operatorPayoutAddress`: must be nil when the masternode's /// registered `operatorReward` is 0, and must be given when it is - /// not; the payload REPLACES the operator payout script on-chain. + /// not — the payload REPLACES the operator payout script on-chain. /// /// Returns the ProUpServTx txid (32 wire-order bytes). A /// `.transactionBroadcastUnconfirmed` error means the outcome is - /// ambiguous: never retry; the wallet reconciles through sync. + /// ambiguous — never retry; the wallet reconciles through sync. public func masternodeUpdateService( walletId: Data, proTxHash: Data, operatorKeyIndex: UInt32, - service: MasternodeServiceConfirmation, + platformP2PPort: UInt16? = nil, operatorPayoutAddress: String? = nil ) async throws -> Data { guard isConfigured, handle != NULL_HANDLE, @@ -444,7 +389,6 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } - try service.validateShape() let handle = self.handle return try await Task.detached(priority: .userInitiated) { () -> Data in @@ -465,27 +409,23 @@ extension PlatformWalletManager { let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_masternode_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - operatorKeyIndex, - cAddress, - nodeId, - p2pPort, - httpPort, - payoutPtr, - resolver.handle, - &txidTuple - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } - } - return call(nil) + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + operatorKeyIndex, + platformP2PPort != nil, + platformP2PPort ?? 0, + payoutPtr, + resolver.handle, + &txidTuple + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } } + return call(nil) } } } @@ -497,20 +437,20 @@ extension PlatformWalletManager { }.value } - /// Prepare, but do not broadcast, the same ProUpServTx + /// Prepare — but do not broadcast — the same ProUpServTx /// `masternodeUpdateService` would send, so the host can show the /// transaction before the user commits to it. /// /// The returned token owns a signed transaction whose inputs are /// reserved: broadcast it with /// `ManagedCoreWallet.broadcastTransactionWithOutcome(_:)`, or let it - /// deinit, which abandons it and releases the reservation. Its `fee` + /// deinit — which abandons it and releases the reservation. Its `fee` /// and `serializedData()` describe exactly what a broadcast would send. public func masternodePrepareUpdateService( walletId: Data, proTxHash: Data, operatorKeyIndex: UInt32, - service: MasternodeServiceConfirmation, + platformP2PPort: UInt16? = nil, operatorPayoutAddress: String? = nil ) async throws -> FinalizedCoreTransaction { guard isConfigured, handle != NULL_HANDLE, @@ -519,7 +459,6 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } - try service.validateShape() let handle = self.handle // Only the raw handle crosses the task boundary; the owning token is @@ -530,27 +469,23 @@ extension PlatformWalletManager { let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_masternode_prepare_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - operatorKeyIndex, - cAddress, - nodeId, - p2pPort, - httpPort, - payoutPtr, - resolver.handle, - &outHandle - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } - } - return call(nil) + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_prepare_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + operatorKeyIndex, + platformP2PPort != nil, + platformP2PPort ?? 0, + payoutPtr, + resolver.handle, + &outHandle + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } } + return call(nil) } } } @@ -565,112 +500,6 @@ extension PlatformWalletManager { } } -/// The service values an update-service ProUpServTx asserts, as the user -/// confirmed them. The payload is built from these values alone. -/// -/// `PlatformWalletManager.masternodeUpdateServiceSuggestion(proTxHash:)` -/// offers what the synced masternode list shows to prefill a form; that is a -/// hint, so show every value and let the user confirm or correct it before -/// building this. The shape (regular or evonode) must match how the -/// masternode was registered; the update refuses a mismatch. -public struct MasternodeServiceConfirmation: Sendable, Equatable { - /// Core P2P endpoint as `"a.b.c.d:port"`. IPv4, and the port is - /// required. - public let serviceAddress: String - /// The evonode platform fields; `nil` for a regular masternode. - public let evonode: EvonodePlatformService? - - public init(serviceAddress: String, evonode: EvonodePlatformService? = nil) { - self.serviceAddress = serviceAddress - self.evonode = evonode - } - - /// The Swift-side shape check the FFI relies on: an evonode's node id - /// must be 20 bytes, since an empty `Data` would reach Rust as a null - /// pointer, which means a regular masternode. - func validateShape() throws { - if let evonode, evonode.platformNodeId.count != 20 { - throw PlatformWalletError.invalidParameter( - "the platform node id must be 20 bytes") - } - } - - /// Calls `body` with the C views the update-service externs take: the - /// address as a C string, then the node id (null for a regular - /// masternode) and the two platform ports (0 for a regular masternode). - /// Call `validateShape()` first. - func withFFIArguments( - _ body: (UnsafePointer, UnsafePointer?, UInt16, UInt16) -> R - ) -> R { - serviceAddress.withCString { cAddress in - guard let evonode else { - return body(cAddress, nil, 0, 0) - } - return evonode.platformNodeId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> R in - body( - cAddress, - raw.baseAddress?.assumingMemoryBound(to: UInt8.self), - evonode.platformP2PPort, - evonode.platformHTTPPort - ) - } - } - } -} - -/// The platform fields of an evonode's update-service payload. -public struct EvonodePlatformService: Sendable, Equatable { - /// Tenderdash node id, 20 bytes. - public let platformNodeId: Data - /// Tenderdash P2P port. The masternode list does not carry it. - public let platformP2PPort: UInt16 - /// Platform HTTP (DAPI) port. - public let platformHTTPPort: UInt16 - - public init(platformNodeId: Data, platformP2PPort: UInt16, platformHTTPPort: UInt16) { - self.platformNodeId = platformNodeId - self.platformP2PPort = platformP2PPort - self.platformHTTPPort = platformHTTPPort - } -} - -/// What the synced masternode list shows for a masternode's service. See -/// `PlatformWalletManager.masternodeUpdateServiceSuggestion(proTxHash:)`: -/// a prefill hint the user confirms, never a payload source. -public struct MasternodeServiceSuggestion: Sendable, Equatable { - /// `"ip:port"` of the Core P2P endpoint the list shows; `nil` for Tor / - /// I2P / domain-only entries. - public let serviceAddress: String? - /// The list shows an evonode entry. - public let isEvonode: Bool - /// Tenderdash node id (20 bytes), evonodes only. - public let platformNodeId: Data? - /// Platform HTTP (DAPI) port, evonodes only. - public let platformHTTPPort: UInt16? - /// The list shows the masternode PoSe-banned. When false, the form may - /// warn that the node looks healthy; updating it is still allowed. - public let poseBanned: Bool - /// The list shows v3 extended network info, which the update refuses - /// (a version-2 payload would replace the whole endpoint map). - public let hasExtendedNetInfo: Bool - - public init( - serviceAddress: String?, - isEvonode: Bool, - platformNodeId: Data?, - platformHTTPPort: UInt16?, - poseBanned: Bool, - hasExtendedNetInfo: Bool - ) { - self.serviceAddress = serviceAddress - self.isEvonode = isEvonode - self.platformNodeId = platformNodeId - self.platformHTTPPort = platformHTTPPort - self.poseBanned = poseBanned - self.hasExtendedNetInfo = hasExtendedNetInfo - } -} - /// Which wallet key signs a masternode (evonode) credit withdrawal. public enum MasternodeWithdrawalSigningKey: Sendable, Equatable { /// The `ProviderOwnerKeys` key — pays the registered payout address only. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift index b78527b7552..e2c21d4c74f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift @@ -215,29 +215,28 @@ extension PlatformWalletManager { }.value } - /// Update service (which also unbans) for a tracked masternode: - /// broadcast a ProUpServTx asserting the service values the user - /// confirmed, which revives it if it is PoSe-banned, signed with the - /// host-vaulted operator key text (64-char hex or 32-byte base64). The - /// L1 network fee is funded from `walletId`'s core funds, so this call, - /// unlike `trackedMasternodeWithdraw`, also signs wallet inputs through - /// the mnemonic resolver. + /// Unban / update-service for a tracked masternode: broadcast a + /// ProUpServTx re-asserting its current service values — which revives + /// it if it is PoSe-banned — signed with the host-vaulted operator key + /// text (64-char hex or 32-byte base64). The L1 network fee is funded + /// from `walletId`'s core funds, so this call — unlike + /// `trackedMasternodeWithdraw` — also signs wallet inputs through the + /// mnemonic resolver. /// - /// - `service`: the confirmed service values; the payload carries these, - /// not the masternode list's entry. Prefill them from - /// `masternodeUpdateServiceSuggestion(proTxHash:)`. + /// - `platformP2PPort`: required for an evonode (the masternode list + /// does not carry it); must be nil for a regular masternode. /// - `operatorPayoutAddress`: must be nil when the masternode's /// registered `operatorReward` is 0, and must be given when it is - /// not; the payload REPLACES the operator payout script on-chain. + /// not — the payload REPLACES the operator payout script on-chain. /// /// Returns the ProUpServTx txid (32 wire-order bytes). A /// `.transactionBroadcastUnconfirmed` error means the outcome is - /// ambiguous: never retry; the wallet reconciles through sync. + /// ambiguous — never retry; the wallet reconciles through sync. public func trackedMasternodeUpdateService( walletId: Data, proTxHash: Data, operatorKey: String, - service: MasternodeServiceConfirmation, + platformP2PPort: UInt16? = nil, operatorPayoutAddress: String? = nil ) async throws -> Data { guard isConfigured, handle != NULL_HANDLE, @@ -246,7 +245,6 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } - try service.validateShape() let handle = self.handle return try await Task.detached(priority: .userInitiated) { () -> Data in @@ -266,27 +264,23 @@ extension PlatformWalletManager { walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in operatorKey.withCString { cKey -> PlatformWalletFFIResult in - service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_tracked_masternode_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - cKey, - cAddress, - nodeId, - p2pPort, - httpPort, - payoutPtr, - resolver.handle, - &txidTuple - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } - } - return call(nil) + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_tracked_masternode_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + platformP2PPort != nil, + platformP2PPort ?? 0, + payoutPtr, + resolver.handle, + &txidTuple + ) } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) } } } @@ -299,18 +293,18 @@ extension PlatformWalletManager { }.value } - /// Prepare, but do not broadcast, the same ProUpServTx + /// Prepare — but do not broadcast — the same ProUpServTx /// `trackedMasternodeUpdateService` would send, so the host can show the /// transaction before the user commits to it. /// /// Ownership matches `masternodePrepareUpdateService`: the returned - /// token holds a signed transaction with reserved inputs; broadcast it, + /// token holds a signed transaction with reserved inputs — broadcast it, /// or let it deinit, which abandons it and releases the reservation. public func trackedMasternodePrepareUpdateService( walletId: Data, proTxHash: Data, operatorKey: String, - service: MasternodeServiceConfirmation, + platformP2PPort: UInt16? = nil, operatorPayoutAddress: String? = nil ) async throws -> FinalizedCoreTransaction { guard isConfigured, handle != NULL_HANDLE, @@ -319,7 +313,6 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or wallet id / proTxHash not 32 bytes") } - try service.validateShape() let handle = self.handle // Only the raw handle crosses the task boundary; the owning token is @@ -331,27 +324,23 @@ extension PlatformWalletManager { walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in operatorKey.withCString { cKey -> PlatformWalletFFIResult in - service.withFFIArguments { (cAddress, nodeId, p2pPort, httpPort) -> PlatformWalletFFIResult in - func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { - platform_wallet_manager_tracked_masternode_prepare_update_service( - handle, - widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), - cKey, - cAddress, - nodeId, - p2pPort, - httpPort, - payoutPtr, - resolver.handle, - &outHandle - ) - } - if let operatorPayoutAddress { - return operatorPayoutAddress.withCString { call($0) } - } - return call(nil) + func call(_ payoutPtr: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_tracked_masternode_prepare_update_service( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + platformP2PPort != nil, + platformP2PPort ?? 0, + payoutPtr, + resolver.handle, + &outHandle + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } } + return call(nil) } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift deleted file mode 100644 index 1744077b88a..00000000000 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/MasternodeServiceConfirmationTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -import XCTest - -@testable import SwiftDashSDK - -/// The confirmed service values reach the update-service externs in the -/// shape Rust reads them: a null node id and zero ports for a regular -/// masternode, the 20-byte node id and both ports for an evonode. -final class MasternodeServiceConfirmationTests: XCTestCase { - func testShouldPassNoPlatformFieldsForARegularMasternode() throws { - let service = MasternodeServiceConfirmation(serviceAddress: "203.0.113.7:9999") - try service.validateShape() - - let (address, hasNodeId, p2pPort, httpPort) = service.withFFIArguments { - cAddress, nodeId, p2pPort, httpPort in - (String(cString: cAddress), nodeId != nil, p2pPort, httpPort) - } - XCTAssertEqual(address, "203.0.113.7:9999") - XCTAssertFalse(hasNodeId, "a null node id is what marks a regular masternode") - XCTAssertEqual(p2pPort, 0) - XCTAssertEqual(httpPort, 0) - } - - func testShouldPassEveryConfirmedFieldForAnEvonode() throws { - let nodeId = Data(repeating: 0x5A, count: 20) - let service = MasternodeServiceConfirmation( - serviceAddress: "203.0.113.8:9999", - evonode: EvonodePlatformService( - platformNodeId: nodeId, platformP2PPort: 26656, platformHTTPPort: 443)) - try service.validateShape() - - let (address, passedNodeId, p2pPort, httpPort) = service.withFFIArguments { - cAddress, nodeIdPtr, p2pPort, httpPort in - ( - String(cString: cAddress), - nodeIdPtr.map { Data(bytes: $0, count: 20) }, - p2pPort, - httpPort - ) - } - XCTAssertEqual(address, "203.0.113.8:9999") - XCTAssertEqual(passedNodeId, nodeId) - XCTAssertEqual(p2pPort, 26656) - XCTAssertEqual(httpPort, 443) - } - - /// An empty `Data` would reach Rust as a null pointer, which reads as a - /// regular masternode, so a short node id is refused on the Swift side. - func testShouldRefuseAnEvonodeNodeIdThatIsNotTwentyBytes() { - for length in [0, 19, 21] { - let service = MasternodeServiceConfirmation( - serviceAddress: "203.0.113.8:9999", - evonode: EvonodePlatformService( - platformNodeId: Data(repeating: 1, count: length), - platformP2PPort: 26656, - platformHTTPPort: 443)) - XCTAssertThrowsError(try service.validateShape(), "length \(length)") - } - } -}