diff --git a/Cargo.lock b/Cargo.lock index 56d1049bda9..c7a6df73ef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "bincode", "dashcore-private", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" [[package]] name = "glob" @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a#3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" +source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 7378f563100..7afa014d748 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "3d13d9838c80fb5e67cf1f62cf5f3f4477bd5b9a" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index e146feb3406..003eae84439 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -632,6 +632,9 @@ impl From for PlatformWalletFFIResult { // The core-transaction sibling of the shielded pair above: the // do-not-retry signal must survive the boundary as a typed code // so hosts can distinguish it from a definitive rejection. + PlatformWalletError::MasternodeListUnavailable => { + PlatformWalletFFIResultCode::ErrorMasternodeListUnavailable + } PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 7bd2004b425..74aa7598e08 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -58,6 +58,7 @@ pub mod managed_identity; pub mod manager; pub mod manager_diagnostics; pub mod masternode_locator; +pub mod masternode_update_service; pub mod masternode_withdrawal; pub mod memory_explorer; pub mod mnemonic_words; diff --git a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs index 372ccdb8277..30e2dd18e93 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs @@ -320,6 +320,7 @@ mod tests { platform_node_id: Some([4u8; 20]), is_valid: true, is_evonode: true, + has_extended_net_info: false, } } diff --git a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs new file mode 100644 index 00000000000..cbd03c502f2 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs @@ -0,0 +1,439 @@ +//! 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: +//! +//! - [`platform_wallet_manager_masternode_update_service`][]: wallet-owned +//! masternodes — the operator key is derived from the wallet's +//! `ProviderOperatorKeys` account at `operator_key_index` (the index the +//! masternode record's derive-and-compare join already resolved). +//! - [`platform_wallet_manager_tracked_masternode_update_service`][]: tracked +//! masternodes — the operator key is the host-vaulted key text (64-char +//! 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 +//! `ErrorTransactionBroadcastUnconfirmed` and the reserved inputs stay held +//! for the wallet's normal reconciliation — never retry the call on that +//! code. + +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, LocatorSecret, MasternodeKeyRole, + MasternodeUpdateServiceParams, +}; +use platform_wallet::{PlatformWallet, ProviderKeyKind}; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; +use zeroize::Zeroizing; + +use crate::error::*; +use crate::handle::*; +use crate::identity_keys_from_mnemonic::resolve_seed_from_resolver; +use crate::runtime::block_on_worker; +use crate::tracked_masternode::{invalid_handle, optional_string}; +use crate::{check_ptr, unwrap_result_or_return}; + +/// Everything both externs snapshot from the manager before releasing the +/// handle-storage guard, so the network work runs unguarded. +struct ResolvedContext { + wallet: Arc, + spv: Arc, + network: dashcore::Network, +} + +unsafe fn resolve_context( + manager_handle: Handle, + wallet_id: *const u8, +) -> Result { + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let resolved = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + ( + manager.get_wallet_blocking(&wid), + manager.spv_arc(), + manager.sdk().network, + ) + }); + match resolved { + None => Err(invalid_handle()), + Some((None, _, _)) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::NotFound, + "wallet not found in the manager", + )), + Some((Some(wallet), spv, network)) => Ok(ResolvedContext { + wallet, + spv, + network, + }), + } +} + +/// Derive the wallet's operator BLS secret (big-endian scalar) at `index`, +/// resolving the raw BIP39 seed through the mnemonic resolver when the +/// wallet has no resident keys — the same three phases as +/// `platform_wallet_provider_key_at_index`, with the resolver never invoked +/// under a wallet guard. +unsafe fn wallet_operator_secret( + wallet: &Arc, + index: u32, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, +) -> Result, PlatformWalletFFIResult> { + // Phase 1 — capability probe under a SHORT read guard, dropped before + // any resolver interaction. + let is_resident = { + let wm = wallet.wallet_manager().blocking_read(); + match wm.get_wallet(&wallet.wallet_id()) { + Some(key_wallet) => !key_wallet.is_external_signable() && !key_wallet.is_watch_only(), + None => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "wallet not found in wallet manager", + )); + } + } + }; + + // Phase 2 — resolve the raw BIP39 seed for external-signable / + // watch-only wallets. The resolver synchronously re-enters Swift and + // reads the iOS Keychain, so never under a wallet guard. + let mut seed_opt: Option> = None; + if !is_resident { + if mnemonic_resolver_handle.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "this wallet has no resident private keys (external-signable / watch-only); \ + a mnemonic resolver handle is required to derive the operator key", + )); + } + let wallet_id = wallet.wallet_id(); + seed_opt = Some(resolve_seed_from_resolver( + mnemonic_resolver_handle, + &wallet_id, + )?); + } + + // Phase 3 — library derive; the resolver, if any, has already run. + let derived = wallet + .derive_provider_key_at_index( + ProviderKeyKind::Operator, + index, + seed_opt.as_deref().map(|s| &s[..]), + true, + ) + .map_err(PlatformWalletFFIResult::from)?; + let private = derived.private_key.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "the wallet did not return the operator private key", + ) + })?; + // Copy straight into zeroizing storage — a plain `[u8; 32]` intermediate + // is `Copy` and would leave an unscrubbed stack copy of the secret. + if private.len() != 32 { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "the derived operator private key is not 32 bytes", + )); + } + let mut bytes = Zeroizing::new([0u8; 32]); + bytes.copy_from_slice(private.as_slice()); + Ok(bytes) +} + +#[allow(clippy::too_many_arguments)] +unsafe fn run_update_service( + context: ResolvedContext, + 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 target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let operator_payout_address = match optional_string(operator_payout_address) { + Ok(text) => text, + Err(e) => return e, + }; + let params = MasternodeUpdateServiceParams { + pro_tx_hash: target, + platform_p2p_port: has_platform_p2p_port.then_some(platform_p2p_port), + operator_payout_address, + }; + + let ResolvedContext { + wallet, + spv, + network, + } = context; + let wallet_id_bytes = wallet.wallet_id(); + // Cross the Send boundary as usize; the handle is borrowed, never + // destroyed — the calling thread blocks for the duration. + let signer_addr = mnemonic_resolver_handle as usize; + let txid = unwrap_result_or_return!(block_on_worker(async move { + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id_bytes, + network, + ); + execute_masternode_update_service(&wallet, &spv, params, operator_secret, &signer).await + })); + + *out_txid = txid.to_raw_hash().to_byte_array(); + 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). +/// +/// - `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 +/// `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. +/// +/// On `ErrorTransactionBroadcastUnconfirmed` the outcome is ambiguous: the +/// 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. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + operator_key_index: u32, + 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 { + // `out_txid` first: the zero-on-every-path contract must hold even + // when a later required pointer is null. + check_ptr!(out_txid); + *out_txid = [0u8; 32]; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(mnemonic_resolver_handle); + + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let operator_secret = match wallet_operator_secret( + &context.wallet, + operator_key_index, + mnemonic_resolver_handle, + ) { + Ok(secret) => secret, + Err(e) => return e, + }; + run_update_service( + context, + pro_tx_hash, + 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 +/// 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. +/// +/// Parameters and outcome semantics are identical to +/// [`platform_wallet_manager_masternode_update_service`], with +/// `operator_key_text` replacing `operator_key_index`. The key is verified +/// against the masternode-list entry's operator public key (basic or legacy +/// 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. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_service( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + operator_key_text: *const c_char, + 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 { + // `out_txid` first: the zero-on-every-path contract must hold even + // when a later required pointer is null. + check_ptr!(out_txid); + *out_txid = [0u8; 32]; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(operator_key_text); + check_ptr!(mnemonic_resolver_handle); + + 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, + }; + let secret = match parse_secret_for_role(key_text, MasternodeKeyRole::Operator, context.network) + { + // Move the existing zeroizing container; dereferencing it would + // place a `Copy` of the secret on the stack. + Ok(LocatorSecret::Bls(secret)) => secret, + Ok(_) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the operator key must be a BLS secret (64-char hex or 32-byte base64)", + ); + } + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("operator key is not usable: {e}"), + ); + } + }; + run_update_service( + context, + pro_tx_hash, + secret, + has_platform_p2p_port, + platform_p2p_port, + operator_payout_address, + mnemonic_resolver_handle, + out_txid, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform_wallet_ffi_result_free; + + /// Unknown manager handles must come back as `ErrorInvalidHandle` with + /// the out-param still zeroed — mirroring the withdraw pair's contract. + #[test] + fn unknown_handles_are_invalid_handles() { + unsafe { + let wallet_id = [0u8; 32]; + let pro_tx_hash = [0u8; 32]; + let mut txid = [0xAAu8; 32]; + // A dangling-but-non-null resolver pointer is fine: the handle + // lookup fails before the resolver is ever touched. + let resolver = std::ptr::dangling_mut::(); + + let result = platform_wallet_manager_masternode_update_service( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + false, + 0, + std::ptr::null(), + resolver, + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(txid, [0u8; 32], "out_txid is zeroed on every path"); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + + let mut txid = [0xAAu8; 32]; + 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(), + false, + 0, + std::ptr::null(), + resolver, + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(txid, [0u8; 32], "out_txid is zeroed on every path"); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } + } + + /// Null required pointers are rejected before anything else runs — + /// and a valid `out_txid` is still zeroed first, per its contract. + #[test] + fn null_pointers_are_rejected() { + unsafe { + let wallet_id = [0u8; 32]; + let pro_tx_hash = [0u8; 32]; + 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, + false, + 0, + std::ptr::null(), + std::ptr::null_mut(), + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_eq!( + txid, [0u8; 32], + "out_txid is zeroed before other pointer checks" + ); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + + let mut txid = [0xAAu8; 32]; + let result = platform_wallet_manager_tracked_masternode_update_service( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + std::ptr::null(), + false, + 0, + std::ptr::null(), + std::ptr::dangling_mut::(), + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_eq!( + txid, [0u8; 32], + "out_txid is zeroed before other pointer checks" + ); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs b/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs index 7f51820e6fe..8c6adc508c5 100644 --- a/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs +++ b/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs @@ -17,14 +17,16 @@ use crate::handle::*; use crate::runtime::block_on_worker; use crate::{check_ptr, unwrap_result_or_return}; -fn invalid_handle() -> PlatformWalletFFIResult { +pub(crate) fn invalid_handle() -> PlatformWalletFFIResult { PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidHandle, "invalid platform wallet manager handle", ) } -unsafe fn optional_string(ptr: *const c_char) -> Result, PlatformWalletFFIResult> { +pub(crate) unsafe fn optional_string( + ptr: *const c_char, +) -> Result, PlatformWalletFFIResult> { if ptr.is_null() { return Ok(None); } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index d2b50861297..d3a53cade57 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -415,6 +415,13 @@ pub enum PlatformWalletError { #[error("Invalid parameter: {0}")] InvalidParameter(String), + /// The SPV masternode list has not been synced yet, so a + /// masternode-list-dependent action (locating an entry, re-asserting its + /// service values) cannot proceed. FFI maps it to the same + /// masternode-list-unavailable code the locator already returns. + #[error("the masternode list is not available yet")] + MasternodeListUnavailable, + #[error( "no selectable inputs: only funded addresses appear as destinations \ (funded_outputs={funded_outputs:?}, sub_min_count={sub_min_count}, \ diff --git a/packages/rs-platform-wallet/src/masternode/list.rs b/packages/rs-platform-wallet/src/masternode/list.rs index d49590c8bb0..41614c029a5 100644 --- a/packages/rs-platform-wallet/src/masternode/list.rs +++ b/packages/rs-platform-wallet/src/masternode/list.rs @@ -43,6 +43,14 @@ pub struct MasternodeListSummary { pub is_valid: bool, /// High-performance (evonode) entry. pub is_evonode: bool, + /// The entry advertises v3 extended network info (an `ExtNetInfo` + /// endpoint map) rather than a single legacy address — + /// `service_address` is then only its primary endpoint. The + /// update-service (unban) path refuses such entries, since a version-2 + /// ProUpServTx would replace the whole endpoint map with one address. + /// Snapshots persisted before this field existed default to `false`; + /// that guard reads only live list summaries. + pub has_extended_net_info: bool, } impl MasternodeListSummary { @@ -77,6 +85,10 @@ impl MasternodeListSummary { platform_node_id, is_valid: entry.is_valid, is_evonode, + has_extended_net_info: matches!( + entry.service_address, + dashcore::sml::masternode_list_entry::MasternodeNetInfo::Extended(_) + ), } } @@ -165,6 +177,7 @@ pub(crate) mod test_support { platform_node_id: None, is_valid: true, is_evonode: false, + has_extended_net_info: false, } } diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index ae7a23d30cf..7288279841c 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -12,6 +12,7 @@ pub mod list; pub mod locator; pub mod record; pub mod tracked; +pub mod update_service; pub use list::{find_in_summaries, MasternodeListQuery, MasternodeListSummary}; pub use locator::{ @@ -29,6 +30,7 @@ pub use tracked::{ capabilities_for_roles, snapshot_from_json, snapshot_to_json, MasternodeCapabilities, PlatformKeySnapshot, RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot, }; +pub use update_service::{execute_masternode_update_service, MasternodeUpdateServiceParams}; use crate::changeset::PlatformWalletPersistence; use crate::manager::PlatformWalletManager; diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs index 5c0933aa8f5..66e179015b5 100644 --- a/packages/rs-platform-wallet/src/masternode/tracked.rs +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -305,6 +305,7 @@ fn list_to_json(list: &MasternodeListSummary) -> Value { "platformNodeId": hex_opt(list.platform_node_id), "isValid": list.is_valid, "isEvonode": list.is_evonode, + "hasExtendedNetInfo": list.has_extended_net_info, }) } @@ -320,6 +321,9 @@ fn list_from_json(value: &Value) -> Option { platform_node_id: parse_hex(&value["platformNodeId"]), is_valid: value["isValid"].as_bool()?, is_evonode: value["isEvonode"].as_bool()?, + // Absent on snapshots persisted before the field existed; the + // next refresh rewrites it from the live entry. + has_extended_net_info: value["hasExtendedNetInfo"].as_bool().unwrap_or(false), }) } diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs new file mode 100644 index 00000000000..86c26bb1992 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -0,0 +1,709 @@ +//! 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 +//! `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. + +use dashcore::blockdata::script::ScriptBuf; +use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; +use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; +use dashcore::blockdata::transaction::special_transaction::{ + SpecialTransactionBasePayloadEncodable, TransactionPayload, +}; +use dashcore::bls_sig_utils::BLSSignature; +use dashcore::blsful::{Bls12381G2Impl, SecretKey as BlsSecretKey, SignatureSchemes}; +use dashcore::hash_types::InputsHash; +use dashcore::hashes::Hash; +use dashcore::platform_node_id::PlatformNodeId; +use dashcore::{Address as DashAddress, Network, Txid}; +use key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, TransactionSigner, +}; +use std::net::{IpAddr, SocketAddr}; +use zeroize::Zeroizing; + +use super::list::MasternodeListSummary; +use super::locator::bls_public_keys; +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +use crate::spv::SpvRuntime; +use crate::wallet::core::{CoreWallet, 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. +#[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, + /// 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 + /// from happening silently. + pub operator_payout_address: Option, +} + +/// 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. +/// +/// `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 +/// the masternode-list entry's operator public key (both basic and legacy +/// serializations) before any network work. `signer` signs the wallet's +/// funding inputs and never sees the operator key. +/// +/// The fee is paid from `wallet`'s core funds ([`SEND_FUNDING_SOURCES`]). +/// On a definitively rejected broadcast the reserved inputs are released; +/// on an ambiguous outcome the error is +/// [`PlatformWalletError::TransactionBroadcastUnconfirmed`] and the inputs +/// stay reserved for the wallet's normal reconciliation. +pub async fn execute_masternode_update_service( + wallet: &PlatformWallet, + spv: &SpvRuntime, + params: MasternodeUpdateServiceParams, + operator_secret: Zeroizing<[u8; 32]>, + signer: &S, +) -> Result { + let summaries = spv + .masternode_list_summaries() + .await + .ok_or(PlatformWalletError::MasternodeListUnavailable)?; + let entry = summaries + .iter() + .find(|entry| entry.pro_tx_hash == params.pro_tx_hash) + .ok_or_else(|| { + PlatformWalletError::InvalidParameter(format!( + "masternode {} is not in the masternode list", + display_hex(¶ms.pro_tx_hash) + )) + })?; + + verify_operator_secret(&entry.operator_public_key, &operator_secret)?; + + let operator_reward = fetch_operator_reward(wallet, ¶ms.pro_tx_hash).await?; + let script_payout = resolve_operator_payout_script( + operator_reward, + params.operator_payout_address.as_deref(), + wallet.network(), + )?; + + let placeholder = + prepare_update_service_placeholder(entry, params.platform_p2p_port, script_payout)?; + + build_sign_broadcast_update_service(wallet.core(), placeholder, operator_secret, signer).await +} + +/// 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 { + let display = display_hex(pro_tx_hash); + let fetched = wallet + .sdk() + .get_transaction(&display) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "failed to fetch the registration transaction: {e}" + )) + })? + .ok_or_else(|| { + PlatformWalletError::InvalidParameter(format!( + "registration transaction {display} was not found; cannot determine the \ + operator reward" + )) + })?; + operator_reward_from_registration(pro_tx_hash, &fetched.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 +/// 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( + pro_tx_hash: &[u8; 32], + transaction: &dashcore::Transaction, +) -> Result { + let expected = Txid::from_byte_array(*pro_tx_hash); + let actual = transaction.txid(); + if actual != expected { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "DAPI returned transaction {actual} for requested registration transaction \ + {expected}" + ))); + } + match &transaction.special_transaction_payload { + Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => { + Ok(registration.operator_reward) + } + _ => Err(PlatformWalletError::InvalidParameter(format!( + "transaction {expected} is not a provider registration transaction" + ))), + } +} + +/// The operator payout rule, decided with the wallet owner (2026-08-27): +/// the payload's payout script REPLACES the current one at consensus level +/// and an empty script clears it, while consensus also forbids a payout +/// script entirely when the masternode's `operatorReward` is zero. So: +/// reward 0 ⇒ the script is always empty and an address must not be given; +/// reward non-zero ⇒ the caller must supply the address explicitly — never +/// default to empty, which would silently clear the operator's payout. +pub(crate) fn resolve_operator_payout_script( + operator_reward: u16, + operator_payout_address: Option<&str>, + network: Network, +) -> Result { + match (operator_reward, operator_payout_address) { + (0, None) => Ok(ScriptBuf::new()), + (0, Some(_)) => Err(PlatformWalletError::InvalidParameter( + "this masternode's operatorReward is 0, so an operator payout address is not \ + allowed" + .to_string(), + )), + (reward, None) => Err(PlatformWalletError::InvalidParameter(format!( + "this masternode pays a {}.{:02}% operator reward; the operator payout address \ + must be confirmed explicitly — an empty payout script would clear it on-chain", + reward / 100, + reward % 100 + ))), + (_, Some(address)) => { + let address = address + .parse::>() + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "operator payout address is not a valid Dash address: {e}" + )) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "operator payout address is for another network: {e}" + )) + })?; + Ok(address.script_pubkey()) + } + } +} + +/// Refuse an operator secret whose public key does not match the +/// masternode-list entry, before any network work. The entry may carry the +/// basic (v19+) or legacy serialization of the same key, so both are +/// accepted — mirroring `verify_masternode_key`. +pub(crate) fn verify_operator_secret( + expected_operator_key: &[u8; 48], + operator_secret: &[u8; 32], +) -> Result<(), PlatformWalletError> { + let (basic, legacy) = bls_public_keys(operator_secret).ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "the operator key is not a valid BLS secret key".to_string(), + ) + })?; + if expected_operator_key != &basic && expected_operator_key != &legacy { + return Err(PlatformWalletError::InvalidParameter( + "the operator key does not match this masternode's operator public key".to_string(), + )); + } + Ok(()) +} + +/// 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( + entry: &MasternodeListSummary, + platform_p2p_port: Option, + 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); + + // 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(), + ) + })?; + ( + 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(ProviderMasternodeType::Regular as u16), + None, + None, + None, + ) + }; + + Ok(ProviderUpdateServicePayload::new( + mn_type, + Txid::from_byte_array(entry.pro_tx_hash), + ip_address, + port, + script_payout, + InputsHash::all_zeros(), + platform_node_id, + platform_p2p_port, + platform_http_port, + BLSSignature::from([0u8; 96]), + )) +} + +/// A service socket address as the payload encodes it: the IPv6 (or +/// IPv4-mapped-IPv6) octets as a little-endian `u128`, and the port in host +/// order (the payload serializer byte-swaps it on the wire). +pub(crate) fn service_payload_fields(service: SocketAddr) -> (u128, u16) { + let octets = match service.ip() { + IpAddr::V4(v4) => v4.to_ipv6_mapped().octets(), + IpAddr::V6(v6) => v6.octets(), + }; + (u128::from_le_bytes(octets), service.port()) +} + +/// Fund, finalize, and broadcast 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. +pub(crate) async fn build_sign_broadcast_update_service( + core: &CoreWallet, + placeholder: ProviderUpdateServicePayload, + operator_secret: Zeroizing<[u8; 32]>, + signer: &S, +) -> Result +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 signed = core + .finalize_transaction(builder, &SEND_FUNDING_SOURCES, 0, signer) + .await?; + core.broadcast_finalized_transaction(&signed).await +} + +fn display_hex(pro_tx_hash: &[u8; 32]) -> String { + let mut display = *pro_tx_hash; + display.reverse(); + hex::encode(display) +} + +#[cfg(test)] +mod tests { + use super::super::list::test_support::{evonode, masternode}; + use super::*; + use crate::broadcaster::BroadcastError; + use crate::test_support::funded_wallet_manager; + use dashcore::blsful::{PublicKey as BlsPublicKey, Signature as BlsSignature}; + use dashcore::Transaction; + use key_wallet::account::StandardAccountType; + use std::sync::{Arc, Mutex}; + + /// A fixed valid BLS12-381 secret scalar (big-endian, below the group + /// order) so the test keypair is deterministic. + const OPERATOR_SECRET: [u8; 32] = [7u8; 32]; + + fn operator_entry(seed: u8, evo: bool) -> MasternodeListSummary { + let (basic, _) = bls_public_keys(&OPERATOR_SECRET).expect("valid test scalar"); + let mut entry = if evo { evonode(seed) } else { masternode(seed) }; + entry.operator_public_key = basic; + entry + } + + #[derive(Default)] + struct RecordingBroadcaster { + sent: Mutex>, + } + + #[async_trait::async_trait] + impl TransactionBroadcaster for RecordingBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + self.sent + .lock() + .expect("broadcaster lock") + .push(transaction.clone()); + Ok(transaction.txid()) + } + } + + /// The IPv4-mapped little-endian encoding, pinned against the known + /// testnet ProUpServTx vector in dashcore's own payload tests + /// (52.36.64.148:19999). + #[test] + fn service_fields_match_the_known_testnet_vector() { + let service: SocketAddr = "52.36.64.148:19999".parse().expect("socket address"); + let (ip_address, port) = service_payload_fields(service); + let expected: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF, 52, 36, 64, 148]; + assert_eq!(ip_address.to_le_bytes(), expected); + assert_eq!(port, 19999); + } + + #[test] + fn payout_rule_reward_zero_requires_no_address() { + let script = resolve_operator_payout_script(0, None, Network::Testnet) + .expect("reward 0 with no address"); + assert!(script.is_empty(), "reward 0 always sends the empty script"); + + let dummy = DashAddress::dummy(Network::Testnet, 3).to_string(); + let err = resolve_operator_payout_script(0, Some(&dummy), Network::Testnet) + .expect_err("reward 0 with an address must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + #[test] + fn payout_rule_nonzero_reward_requires_an_explicit_address() { + let err = resolve_operator_payout_script(500, None, Network::Testnet) + .expect_err("a non-zero reward with no address must be refused, never cleared"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let dummy = DashAddress::dummy(Network::Testnet, 3); + let script = + resolve_operator_payout_script(500, Some(&dummy.to_string()), Network::Testnet) + .expect("explicit address accepted"); + assert_eq!(script, dummy.script_pubkey()); + } + + #[test] + fn payout_rule_rejects_an_address_for_another_network() { + let mainnet = DashAddress::dummy(Network::Mainnet, 3).to_string(); + let err = resolve_operator_payout_script(500, Some(&mainnet), Network::Testnet) + .expect_err("network mismatch must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + #[test] + fn operator_secret_matches_basic_or_legacy_serialization_only() { + let (basic, legacy) = bls_public_keys(&OPERATOR_SECRET).expect("valid test scalar"); + + verify_operator_secret(&basic, &OPERATOR_SECRET).expect("basic serialization matches"); + verify_operator_secret(&legacy, &OPERATOR_SECRET).expect("legacy serialization matches"); + + let err = verify_operator_secret(&[0x42; 48], &OPERATOR_SECRET) + .expect_err("a different operator key must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // 0xFF.. exceeds the BLS12-381 scalar field order. + let err = verify_operator_secret(&basic, &[0xFF; 32]) + .expect_err("an out-of-range scalar must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + #[test] + fn placeholder_for_a_regular_masternode_omits_the_platform_fields() { + let entry = operator_entry(0x11, false); + let payload = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + .expect("regular placeholder"); + assert_eq!( + payload.version, + ProviderUpdateServicePayload::CURRENT_VERSION + ); + assert_eq!( + payload.mn_type, + Some(ProviderMasternodeType::Regular as u16) + ); + assert_eq!( + payload.pro_tx_hash, + Txid::from_byte_array(entry.pro_tx_hash) + ); + assert_eq!(payload.platform_node_id, None); + assert_eq!(payload.platform_p2p_port, None); + 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(_))); + } + + #[test] + fn placeholder_for_an_evonode_requires_and_carries_the_platform_triplet() { + let entry = operator_entry(0x22, true); + 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.platform_node_id, + entry.platform_node_id.map(PlatformNodeId::from_byte_array) + ); + assert_eq!(payload.platform_p2p_port, Some(26656)); + assert_eq!(payload.platform_http_port, entry.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"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + #[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. + 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()) + .expect_err("an extended-net-info entry must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + fn registration_transaction(operator_reward: u16) -> Transaction { + use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderRegistrationPayload; + use dashcore::bls_sig_utils::BLSPublicKey; + use dashcore::{OutPoint, PubkeyHash}; + + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::ProviderRegistrationPayloadType( + ProviderRegistrationPayload { + version: ProviderRegistrationPayload::CURRENT_VERSION, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }, + service_address: "10.0.0.1:9999".parse().expect("socket address"), + owner_key_hash: PubkeyHash::from_byte_array([1; 20]), + operator_public_key: BLSPublicKey::from([2; 48]), + voting_key_hash: PubkeyHash::from_byte_array([3; 20]), + operator_reward, + script_payout: ScriptBuf::new(), + inputs_hash: InputsHash::all_zeros(), + signature: vec![], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + }, + )), + } + } + + /// The DAPI get-transaction reply is unauthenticated: the payload is + /// trusted only after the decoded transaction hashes to the requested + /// proTxHash. + #[test] + fn operator_reward_binds_the_fetched_transaction_to_the_request() { + let transaction = registration_transaction(500); + let matching = transaction.txid().to_byte_array(); + + let reward = operator_reward_from_registration(&matching, &transaction) + .expect("a matching registration transaction is accepted"); + assert_eq!(reward, 500); + + 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(_))); + + // The right txid but not a ProRegTx payload. + 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) + .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() { + 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")); + let broadcaster = Arc::new(RecordingBroadcaster::default()); + let core = CoreWallet::new( + sdk, + wallet_manager, + wallet_id, + broadcaster.clone(), + generation, + ); + + let entry = operator_entry(0x44, true); + let placeholder = prepare_update_service_placeholder(&entry, Some(26656), ScriptBuf::new()) + .expect("placeholder"); + + let txid = build_sign_broadcast_update_service( + &core, + placeholder, + Zeroizing::new(OPERATOR_SECRET), + &signer, + ) + .await + .expect("update service builds and broadcasts"); + + let sent = broadcaster.sent.lock().expect("broadcaster lock"); + assert_eq!(sent.len(), 1, "exactly one transaction broadcast"); + let tx = &sent[0]; + assert_eq!(tx.txid(), txid); + assert_eq!(tx.version, 3); + assert!( + tx.input.iter().all(|input| !input.script_sig.is_empty()), + "every funding input is ECDSA-signed" + ); + + let Some(TransactionPayload::ProviderUpdateServicePayloadType(payload)) = + &tx.special_transaction_payload + else { + panic!("the broadcast transaction must carry the ProUpServTx payload"); + }; + assert_eq!( + payload.inputs_hash, + tx.hash_inputs(), + "inputs_hash commits to the selected inputs" + ); + assert_eq!( + 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_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 + // `verify_message_digest` checks real mainnet signatures with. + let secret = Option::>::from( + BlsSecretKey::::from_be_bytes(&OPERATOR_SECRET), + ) + .expect("valid test scalar"); + let public_key = BlsPublicKey::from(&secret); + let signature: BlsSignature = payload + .payload_sig + .try_into() + .expect("compressed signature decodes"); + signature + .verify(&public_key, payload.base_payload_hash().as_byte_array()) + .expect("operator BLS signature verifies over base_payload_hash"); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 0a8c08f4a15..484e99dd046 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -357,6 +357,85 @@ extension PlatformWalletManager { return outBalance }.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 + /// 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. + /// - `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. + /// + /// Returns the ProUpServTx txid (32 wire-order bytes). A + /// `.transactionBroadcastUnconfirmed` error means the outcome is + /// ambiguous — never retry; the wallet reconciles through sync. + public func masternodeUpdateService( + walletId: Data, + proTxHash: Data, + operatorKeyIndex: UInt32, + platformP2PPort: UInt16? = nil, + operatorPayoutAddress: String? = nil + ) async throws -> Data { + guard isConfigured, handle != NULL_HANDLE, + walletId.count == 32, proTxHash.count == 32 + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or wallet id / proTxHash not 32 bytes") + } + + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> Data in + // Resolver-backed signer: derives the operator key (and signs + // the funding inputs) with the mnemonic fetched from the + // Keychain inside the resolver vtable Rust-side. Kept alive + // across the synchronous FFI call, whose callback fires during it. + let resolver = MnemonicResolver() + var txidTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + 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) } + } + return call(nil) + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } + }.value + } } /// Which wallet key signs a masternode (evonode) credit withdrawal. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift index 4f6795d5f55..911314a095f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTrackedMasternodes.swift @@ -215,6 +215,84 @@ 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. + /// + /// - `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. + /// + /// Returns the ProUpServTx txid (32 wire-order bytes). A + /// `.transactionBroadcastUnconfirmed` error means the outcome is + /// ambiguous — never retry; the wallet reconciles through sync. + public func trackedMasternodeUpdateService( + walletId: Data, + proTxHash: Data, + operatorKey: String, + platformP2PPort: UInt16? = nil, + operatorPayoutAddress: String? = nil + ) async throws -> Data { + guard isConfigured, handle != NULL_HANDLE, + walletId.count == 32, proTxHash.count == 32 + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or wallet id / proTxHash not 32 bytes") + } + + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> Data in + // Resolver-backed signer for the funding inputs; the operator + // key itself is the host-supplied text, never the wallet's. + let resolver = MnemonicResolver() + var txidTuple: ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) = ( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ) + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + 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 + ) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) + } + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } + }.value + } + // MARK: - Shared marshalling /// One-entry-call helper for FFI functions returning a masternode