diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 74aa7598e0..ff1795cc40 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_registrar; pub mod masternode_update_service; pub mod masternode_withdrawal; pub mod memory_explorer; diff --git a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs index 30e2dd18e9..66a1e3af78 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs @@ -321,6 +321,7 @@ mod tests { is_valid: true, is_evonode: true, has_extended_net_info: false, + operator_key_is_legacy: false, } } diff --git a/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs new file mode 100644 index 0000000000..270d9c8dae --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs @@ -0,0 +1,914 @@ +//! FFI bindings for the masternode key-rotation (ProUpRegTx) action and its +//! stage-two reactivation — `platform_wallet::masternode::update_registrar` +//! and the explicit-values update-service. +//! +//! Entry-point families, all additive, mirroring the update-service module: +//! +//! - `..._masternode_update_registrar` / `..._tracked_masternode_update_registrar` +//! (+ `_prepare_` variants): the owner-signed rotation. The wallet form +//! derives the owner key at `owner_key_index`; the tracked form parses +//! the host-vaulted owner key text (WIF or hex). +//! - `..._masternode_update_service_with_values` (+ `_prepare_`): stage two — +//! re-assert caller-captured service values, signed with the (post- +//! rotation, wallet-held) operator key. There is no tracked form: after a +//! rotation the operator key is by definition a wallet key. +//! - `..._provider_key_candidates`: the wallet's operator / voting keys by +//! index with their network-wide usage, for the rotation key picker. +//! +//! Rotating the operator key PoSe-bans the node with its service fields +//! reset until stage two lands — callers capture the entry's service values +//! BEFORE broadcasting the rotation. + +use std::ffi::CString; +use std::os::raw::c_char; + +use dashcore::hashes::Hash; +use platform_wallet::masternode::{ + execute_masternode_update_registrar, execute_masternode_update_service_with_values, + parse_secret_for_role, prepare_masternode_update_registrar, + prepare_masternode_update_service_with_values, provider_key_candidates, LocatorSecret, + MasternodeKeyRole, MasternodeUpdateRegistrarParams, OwnerSecret, ProviderKeyCandidate, + UpdateServiceValues, +}; +use platform_wallet::ProviderKeyKind; +use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; + +use crate::core_wallet::FFICoreSignedTransaction; +use crate::error::*; +use crate::handle::*; +use crate::masternode_update_service::{resolve_context, wallet_provider_secret, ResolvedContext}; +use crate::runtime::block_on_worker; +use crate::tracked_masternode::optional_string; +use crate::{check_ptr, unwrap_result_or_return}; + +/// Parse a host-supplied owner key text (WIF or 64-char hex) into the +/// secp256k1 secret + compression flag the compact signature header needs. +fn tracked_owner_secret( + key_text: &str, + network: dashcore::Network, +) -> Result { + match parse_secret_for_role(key_text, MasternodeKeyRole::Owner, network) { + Ok(LocatorSecret::Ecdsa { secret, compressed }) => Ok(OwnerSecret { secret, compressed }), + Ok(_) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the owner key must be a secp256k1 secret (WIF or 64-char hex)", + )), + Err(e) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("owner key is not usable: {e}"), + )), + } +} + +unsafe fn marshal_registrar_params( + pro_tx_hash: *const u8, + has_new_operator_key_index: bool, + new_operator_key_index: u32, + has_new_voting_key_index: bool, + new_voting_key_index: u32, + payout_address: *const c_char, +) -> Result { + let payout = optional_string(payout_address)?.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the payout address is required: the update replaces the payout script on-chain", + ) + })?; + Ok(MasternodeUpdateRegistrarParams { + pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), + new_operator_key_index: has_new_operator_key_index.then_some(new_operator_key_index), + new_voting_key_index: has_new_voting_key_index.then_some(new_voting_key_index), + payout_address: payout, + }) +} + +enum RegistrarOutcome { + Broadcast(*mut [u8; 32]), + Prepare(*mut Handle), +} + +#[allow(clippy::too_many_arguments)] +unsafe fn run_update_registrar( + context: ResolvedContext, + params: MasternodeUpdateRegistrarParams, + owner: OwnerSecret, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + outcome: RegistrarOutcome, +) -> PlatformWalletFFIResult { + 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; + match outcome { + RegistrarOutcome::Broadcast(out_txid) => { + 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_registrar(&wallet, &spv, params, owner, &signer).await + })); + *out_txid = txid.to_raw_hash().to_byte_array(); + } + RegistrarOutcome::Prepare(out_transaction_handle) => { + let (wallet, prepared) = unwrap_result_or_return!(block_on_worker(async move { + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id_bytes, + network, + ); + prepare_masternode_update_registrar(&wallet, &spv, params, owner, &signer) + .await + .map(|prepared| (wallet, prepared)) + })); + *out_transaction_handle = + CORE_SIGNED_TRANSACTION_STORAGE.insert(FFICoreSignedTransaction { + wallet: wallet.core().clone(), + transaction: prepared, + }); + } + } + PlatformWalletFFIResult::ok() +} + +/// Broadcast a ProUpRegTx rotating a wallet-owned masternode's operator +/// and/or voting key to fresh wallet keys, signed with the wallet's owner +/// key at `owner_key_index`. +/// +/// - `has_new_operator_key_index` / `has_new_voting_key_index` choose what +/// rotates; at least one is required. Rotating the operator key PoSe-bans +/// the node with its service fields reset — capture them first and follow +/// with `platform_wallet_manager_masternode_update_service_with_values`. +/// - `payout_address` is REQUIRED (non-null): the payload replaces the +/// payout script on-chain. +/// - `out_txid` — 32 wire-order bytes, zeroed on every path, written on +/// definitive success. `ErrorTransactionBroadcastUnconfirmed` is +/// ambiguous: never 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_registrar( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + owner_key_index: u32, + has_new_operator_key_index: bool, + new_operator_key_index: u32, + has_new_voting_key_index: bool, + new_voting_key_index: u32, + payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_txid: *mut [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(out_txid); + *out_txid = [0u8; 32]; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(payout_address); + check_ptr!(mnemonic_resolver_handle); + + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let params = match marshal_registrar_params( + pro_tx_hash, + has_new_operator_key_index, + new_operator_key_index, + has_new_voting_key_index, + new_voting_key_index, + payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + let secret = match wallet_provider_secret( + &context.wallet, + ProviderKeyKind::Owner, + owner_key_index, + mnemonic_resolver_handle, + ) { + Ok(secret) => secret, + Err(e) => return e, + }; + // Wallet-derived owner keys are compressed secp256k1 keys. + let owner = OwnerSecret { + secret, + compressed: true, + }; + run_update_registrar( + context, + params, + owner, + mnemonic_resolver_handle, + RegistrarOutcome::Broadcast(out_txid), + ) +} + +/// Prepare-only sibling of +/// [`platform_wallet_manager_masternode_update_registrar`][]: identical up +/// to the broadcast, handing back a core signed-transaction handle with the +/// inputs reserved — broadcast, abandon or free it via the existing +/// `core_wallet_*_signed_transaction` verbs. +/// +/// # Safety +/// As [`platform_wallet_manager_masternode_update_registrar`][]. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_registrar( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + owner_key_index: u32, + has_new_operator_key_index: bool, + new_operator_key_index: u32, + has_new_voting_key_index: bool, + new_voting_key_index: u32, + payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_transaction_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(out_transaction_handle); + *out_transaction_handle = 0; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(payout_address); + check_ptr!(mnemonic_resolver_handle); + + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let params = match marshal_registrar_params( + pro_tx_hash, + has_new_operator_key_index, + new_operator_key_index, + has_new_voting_key_index, + new_voting_key_index, + payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + let secret = match wallet_provider_secret( + &context.wallet, + ProviderKeyKind::Owner, + owner_key_index, + mnemonic_resolver_handle, + ) { + Ok(secret) => secret, + Err(e) => return e, + }; + let owner = OwnerSecret { + secret, + compressed: true, + }; + run_update_registrar( + context, + params, + owner, + mnemonic_resolver_handle, + RegistrarOutcome::Prepare(out_transaction_handle), + ) +} + +/// [`platform_wallet_manager_masternode_update_registrar`][] for a TRACKED +/// masternode: the owner key is the host-vaulted key text (WIF or 64-char +/// hex) instead of a wallet derivation; the fee and the new keys still come +/// from `wallet_id`. +/// +/// # Safety +/// As the wallet form; `owner_key_text` must be a NUL-terminated UTF-8 +/// string. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_update_registrar( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + owner_key_text: *const c_char, + has_new_operator_key_index: bool, + new_operator_key_index: u32, + has_new_voting_key_index: bool, + new_voting_key_index: u32, + payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_txid: *mut [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(out_txid); + *out_txid = [0u8; 32]; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(owner_key_text); + check_ptr!(payout_address); + check_ptr!(mnemonic_resolver_handle); + + let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(owner_key_text).to_str()); + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let params = match marshal_registrar_params( + pro_tx_hash, + has_new_operator_key_index, + new_operator_key_index, + has_new_voting_key_index, + new_voting_key_index, + payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + let owner = match tracked_owner_secret(key_text, context.network) { + Ok(owner) => owner, + Err(e) => return e, + }; + run_update_registrar( + context, + params, + owner, + mnemonic_resolver_handle, + RegistrarOutcome::Broadcast(out_txid), + ) +} + +/// Prepare-only sibling of +/// [`platform_wallet_manager_tracked_masternode_update_registrar`][]. +/// +/// # Safety +/// As the broadcasting form. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_tracked_masternode_prepare_update_registrar( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + owner_key_text: *const c_char, + has_new_operator_key_index: bool, + new_operator_key_index: u32, + has_new_voting_key_index: bool, + new_voting_key_index: u32, + payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_transaction_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(out_transaction_handle); + *out_transaction_handle = 0; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(owner_key_text); + check_ptr!(payout_address); + check_ptr!(mnemonic_resolver_handle); + + let key_text = unwrap_result_or_return!(std::ffi::CStr::from_ptr(owner_key_text).to_str()); + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let params = match marshal_registrar_params( + pro_tx_hash, + has_new_operator_key_index, + new_operator_key_index, + has_new_voting_key_index, + new_voting_key_index, + payout_address, + ) { + Ok(params) => params, + Err(e) => return e, + }; + let owner = match tracked_owner_secret(key_text, context.network) { + Ok(owner) => owner, + Err(e) => return e, + }; + run_update_registrar( + context, + params, + owner, + mnemonic_resolver_handle, + RegistrarOutcome::Prepare(out_transaction_handle), + ) +} + +// MARK: stage two — explicit-values update service + +#[allow(clippy::too_many_arguments)] +unsafe fn marshal_service_values( + service_address: *const c_char, + has_platform_node_id: bool, + platform_node_id: *const u8, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + has_platform_http_port: bool, + platform_http_port: u16, +) -> Result { + let service = optional_string(service_address)?.ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "the service address is required", + ) + })?; + let node_id = if has_platform_node_id { + if platform_node_id.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + "platform_node_id is null despite has_platform_node_id", + )); + } + Some(std::ptr::read(platform_node_id as *const [u8; 20])) + } else { + None + }; + Ok(UpdateServiceValues { + service_address: service, + platform_node_id: node_id, + platform_p2p_port: has_platform_p2p_port.then_some(platform_p2p_port), + platform_http_port: has_platform_http_port.then_some(platform_http_port), + }) +} + +/// Stage two of an operator rotation: broadcast a ProUpServTx re-asserting +/// caller-captured service values (the registrar update reset the entry's +/// own), signed with the wallet's operator key at `operator_key_index` — +/// after a rotation that key is by definition a wallet key, so there is no +/// tracked form. +/// +/// `operator_payout_address` follows the same reward-driven rule as the +/// unban path. `out_txid` is zeroed on every path. +/// +/// # 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_with_values( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + operator_key_index: u32, + service_address: *const c_char, + has_platform_node_id: bool, + platform_node_id: *const u8, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + has_platform_http_port: bool, + platform_http_port: u16, + operator_payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_txid: *mut [u8; 32], +) -> PlatformWalletFFIResult { + check_ptr!(out_txid); + *out_txid = [0u8; 32]; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(service_address); + check_ptr!(mnemonic_resolver_handle); + + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let values = match marshal_service_values( + service_address, + has_platform_node_id, + platform_node_id, + has_platform_p2p_port, + platform_p2p_port, + has_platform_http_port, + platform_http_port, + ) { + Ok(values) => values, + Err(e) => return e, + }; + let operator_payout_address = match optional_string(operator_payout_address) { + Ok(text) => text, + Err(e) => return e, + }; + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let operator_secret = match wallet_provider_secret( + &context.wallet, + ProviderKeyKind::Operator, + operator_key_index, + mnemonic_resolver_handle, + ) { + Ok(secret) => secret, + Err(e) => return e, + }; + + let ResolvedContext { + wallet, + spv, + network, + } = context; + let wallet_id_bytes = wallet.wallet_id(); + 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_with_values( + &wallet, + &spv, + target, + values, + operator_payout_address, + operator_secret, + &signer, + ) + .await + })); + *out_txid = txid.to_raw_hash().to_byte_array(); + PlatformWalletFFIResult::ok() +} + +/// Prepare-only sibling of +/// [`platform_wallet_manager_masternode_update_service_with_values`][]; +/// handle ownership as every other prepare entry point. +/// +/// # Safety +/// As the broadcasting form. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_service_with_values( + manager_handle: Handle, + wallet_id: *const u8, + pro_tx_hash: *const u8, + operator_key_index: u32, + service_address: *const c_char, + has_platform_node_id: bool, + platform_node_id: *const u8, + has_platform_p2p_port: bool, + platform_p2p_port: u16, + has_platform_http_port: bool, + platform_http_port: u16, + operator_payout_address: *const c_char, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + out_transaction_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(out_transaction_handle); + *out_transaction_handle = 0; + check_ptr!(wallet_id); + check_ptr!(pro_tx_hash); + check_ptr!(service_address); + check_ptr!(mnemonic_resolver_handle); + + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + let values = match marshal_service_values( + service_address, + has_platform_node_id, + platform_node_id, + has_platform_p2p_port, + platform_p2p_port, + has_platform_http_port, + platform_http_port, + ) { + Ok(values) => values, + Err(e) => return e, + }; + let operator_payout_address = match optional_string(operator_payout_address) { + Ok(text) => text, + Err(e) => return e, + }; + let target: [u8; 32] = std::ptr::read(pro_tx_hash as *const [u8; 32]); + let operator_secret = match wallet_provider_secret( + &context.wallet, + ProviderKeyKind::Operator, + operator_key_index, + mnemonic_resolver_handle, + ) { + Ok(secret) => secret, + Err(e) => return e, + }; + + let ResolvedContext { + wallet, + spv, + network, + } = context; + let wallet_id_bytes = wallet.wallet_id(); + let signer_addr = mnemonic_resolver_handle as usize; + let (wallet, prepared) = unwrap_result_or_return!(block_on_worker(async move { + let signer = MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id_bytes, + network, + ); + prepare_masternode_update_service_with_values( + &wallet, + &spv, + target, + values, + operator_payout_address, + operator_secret, + &signer, + ) + .await + .map(|prepared| (wallet, prepared)) + })); + *out_transaction_handle = CORE_SIGNED_TRANSACTION_STORAGE.insert(FFICoreSignedTransaction { + wallet: wallet.core().clone(), + transaction: prepared, + }); + PlatformWalletFFIResult::ok() +} + +// MARK: key candidates + +/// Upper bound on one candidates query — mirrors +/// `platform_wallet::masternode::MAX_PROVIDER_KEY_CANDIDATES` (asserted +/// equal in tests); larger counts are refused before any allocation. +pub const PLATFORM_WALLET_PROVIDER_KEY_CANDIDATES_MAX: u32 = 256; + +/// One wallet provider key with its network-wide usage — a rotation +/// key-picker row. +#[repr(C)] +pub struct ProviderKeyCandidateFFI { + pub index: u32, + /// Modern-serialization public key bytes; `public_key_len` says how + /// many are meaningful (48 BLS operator, 33 secp voting). + pub public_key: [u8; 48], + pub public_key_len: u8, + /// Whether a masternode-list entry currently uses this key. + pub used: bool, + /// proTxHash (wire order) of that entry; zeroed when unused. + pub used_by_pro_tx_hash: [u8; 32], + /// P2PKH address (voting keys only) — heap C string, freed by + /// [`platform_wallet_manager_free_provider_key_candidates`]; null for + /// BLS keys. + pub address: *mut c_char, +} + +fn candidate_to_ffi(candidate: ProviderKeyCandidate) -> ProviderKeyCandidateFFI { + let mut public_key = [0u8; 48]; + let len = candidate.public_key_bytes.len().min(48); + public_key[..len].copy_from_slice(&candidate.public_key_bytes[..len]); + let address = candidate + .address + .and_then(|a| CString::new(a).ok()) + .map_or(std::ptr::null_mut(), CString::into_raw); + ProviderKeyCandidateFFI { + index: candidate.index, + public_key, + public_key_len: len as u8, + used: candidate.used_by.is_some(), + used_by_pro_tx_hash: candidate.used_by.unwrap_or([0u8; 32]), + address, + } +} + +/// The wallet's first `count` provider keys of `kind` +/// ([`crate::provider_key_at_index::PROVIDER_KEY_KIND_OPERATOR`] = 10, +/// [`crate::provider_key_at_index::PROVIDER_KEY_KIND_VOTING`] = 8 — the +/// same account-type tags every provider-key FFI uses), each joined against the +/// live masternode list so a rotation picker can default to (and enforce) +/// unused keys. Fails with `ErrorMasternodeListUnavailable` before the list +/// has synced — "unused" cannot be asserted without it. +/// +/// Free with [`platform_wallet_manager_free_provider_key_candidates`]. +/// +/// # Safety +/// Pointer args must be valid; `out_entries` / `out_count` receive a +/// Rust-owned array to be freed exactly once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_provider_key_candidates( + manager_handle: Handle, + wallet_id: *const u8, + kind: u8, + count: u32, + out_entries: *mut *mut ProviderKeyCandidateFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_entries); + check_ptr!(out_count); + *out_entries = std::ptr::null_mut(); + *out_count = 0; + check_ptr!(wallet_id); + + let kind = match kind { + crate::provider_key_at_index::PROVIDER_KEY_KIND_VOTING => ProviderKeyKind::Voting, + crate::provider_key_at_index::PROVIDER_KEY_KIND_OPERATOR => ProviderKeyKind::Operator, + other => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "unsupported provider key kind {other} (expected {} voting or {} operator)", + crate::provider_key_at_index::PROVIDER_KEY_KIND_VOTING, + crate::provider_key_at_index::PROVIDER_KEY_KIND_OPERATOR + ), + ); + } + }; + let context = match resolve_context(manager_handle, wallet_id) { + Ok(context) => context, + Err(e) => return e, + }; + + let ResolvedContext { wallet, spv, .. } = context; + let candidates = unwrap_result_or_return!(block_on_worker(async move { + let summaries = spv + .masternode_list_summaries() + .await + .ok_or(platform_wallet::PlatformWalletError::MasternodeListUnavailable)?; + provider_key_candidates(&wallet, &summaries, kind, count) + })); + + let mut entries: Vec = + candidates.into_iter().map(candidate_to_ffi).collect(); + entries.shrink_to_fit(); + *out_count = entries.len(); + let mut boxed = entries.into_boxed_slice(); + *out_entries = boxed.as_mut_ptr(); + std::mem::forget(boxed); + PlatformWalletFFIResult::ok() +} + +/// Free an array returned by +/// [`platform_wallet_manager_provider_key_candidates`], including each +/// entry's heap address string. +/// +/// # Safety +/// `entries` / `count` must be exactly what the candidates call returned; +/// call once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_provider_key_candidates( + entries: *mut ProviderKeyCandidateFFI, + count: usize, +) { + if entries.is_null() { + return; + } + let boxed = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); + for entry in boxed.iter() { + if !entry.address.is_null() { + drop(CString::from_raw(entry.address)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform_wallet_ffi_result_free; + + #[test] + fn candidates_bound_mirrors_the_core_constant() { + assert_eq!( + PLATFORM_WALLET_PROVIDER_KEY_CANDIDATES_MAX, + platform_wallet::masternode::MAX_PROVIDER_KEY_CANDIDATES + ); + } + + /// Unknown manager handles come back as invalid-handle errors with + /// every out-param left at its zero state — the contract every + /// masternode extern in this crate keeps. + #[test] + fn unknown_handles_are_invalid_handles() { + unsafe { + let wallet_id = [0u8; 32]; + let pro_tx_hash = [0u8; 32]; + let payout = std::ffi::CString::new("yPayout").unwrap(); + let resolver = std::ptr::dangling_mut::(); + + let mut txid = [0xAAu8; 32]; + let result = platform_wallet_manager_masternode_update_registrar( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + true, + 0, + false, + 0, + payout.as_ptr(), + 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 key = std::ffi::CString::new("00").unwrap(); + let mut txid = [0xAAu8; 32]; + let result = platform_wallet_manager_tracked_masternode_update_registrar( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + key.as_ptr(), + true, + 0, + false, + 0, + payout.as_ptr(), + resolver, + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(txid, [0u8; 32]); + 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_registrar( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + true, + 0, + false, + 0, + payout.as_ptr(), + resolver, + &mut transaction_handle, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(transaction_handle, 0); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + + let service = std::ffi::CString::new("1.2.3.4:9999").unwrap(); + let mut txid = [0xAAu8; 32]; + let result = platform_wallet_manager_masternode_update_service_with_values( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + service.as_ptr(), + false, + std::ptr::null(), + false, + 0, + false, + 0, + std::ptr::null(), + resolver, + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(txid, [0u8; 32]); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + + let mut entries: *mut ProviderKeyCandidateFFI = std::ptr::dangling_mut(); + let mut count: usize = 7; + let result = platform_wallet_manager_provider_key_candidates( + Handle::MAX, + wallet_id.as_ptr(), + 10, + 5, + &mut entries, + &mut count, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(entries.is_null()); + assert_eq!(count, 0); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } + } + + /// A missing payout address is refused before the handle lookup could + /// even matter — the payload would replace the payout script on-chain. + #[test] + fn registrar_requires_a_payout_address() { + unsafe { + let wallet_id = [0u8; 32]; + let pro_tx_hash = [0u8; 32]; + let resolver = std::ptr::dangling_mut::(); + let mut txid = [0xAAu8; 32]; + let result = platform_wallet_manager_masternode_update_registrar( + Handle::MAX, + wallet_id.as_ptr(), + pro_tx_hash.as_ptr(), + 0, + true, + 0, + false, + 0, + std::ptr::null(), + resolver, + &mut txid, + ); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_eq!(txid, [0u8; 32]); + let mut result = result; + platform_wallet_ffi_result_free(&mut result); + } + } +} 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 f716669be8..9ad8c24a1b 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_service.rs @@ -44,13 +44,13 @@ 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, +pub(crate) struct ResolvedContext { + pub(crate) wallet: Arc, + pub(crate) spv: Arc, + pub(crate) network: dashcore::Network, } -unsafe fn resolve_context( +pub(crate) unsafe fn resolve_context( manager_handle: Handle, wallet_id: *const u8, ) -> Result { @@ -76,13 +76,15 @@ unsafe fn resolve_context( } } -/// 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 +/// Derive a wallet provider secret (32-byte scalar — big-endian BLS for +/// operator keys, raw secp256k1 for owner keys) 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( +/// under a wallet guard. Shared with the registrar-update module. +pub(crate) unsafe fn wallet_provider_secret( wallet: &Arc, + kind: ProviderKeyKind, index: u32, mnemonic_resolver_handle: *mut MnemonicResolverHandle, ) -> Result, PlatformWalletFFIResult> { @@ -110,7 +112,7 @@ unsafe fn wallet_operator_secret( 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", + a mnemonic resolver handle is required to derive the provider key", )); } let wallet_id = wallet.wallet_id(); @@ -122,17 +124,12 @@ unsafe fn wallet_operator_secret( // 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, - ) + .derive_provider_key_at_index(kind, 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", + "the wallet did not return the provider private key", ) })?; // Copy straight into zeroizing storage — a plain `[u8; 32]` intermediate @@ -140,7 +137,7 @@ unsafe fn wallet_operator_secret( if private.len() != 32 { return Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, - "the derived operator private key is not 32 bytes", + "the derived provider private key is not 32 bytes", )); } let mut bytes = Zeroizing::new([0u8; 32]); @@ -150,7 +147,7 @@ unsafe fn wallet_operator_secret( /// Parse a host-supplied operator key text (64-char hex or 32-byte base64) /// into its BLS secret, shared by the tracked broadcast and prepare externs. -fn tracked_operator_secret( +pub(crate) fn tracked_operator_secret( key_text: &str, network: dashcore::Network, ) -> Result, PlatformWalletFFIResult> { @@ -324,8 +321,9 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service( Ok(context) => context, Err(e) => return e, }; - let operator_secret = match wallet_operator_secret( + let operator_secret = match wallet_provider_secret( &context.wallet, + ProviderKeyKind::Operator, operator_key_index, mnemonic_resolver_handle, ) { @@ -450,8 +448,9 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi Ok(context) => context, Err(e) => return e, }; - let operator_secret = match wallet_operator_secret( + let operator_secret = match wallet_provider_secret( &context.wallet, + ProviderKeyKind::Operator, operator_key_index, mnemonic_resolver_handle, ) { diff --git a/packages/rs-platform-wallet/src/masternode/key_candidates.rs b/packages/rs-platform-wallet/src/masternode/key_candidates.rs new file mode 100644 index 0000000000..5ccfdfdce2 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -0,0 +1,224 @@ +//! Fresh-key candidates for a registrar update: the wallet's provider keys +//! by index, each joined against the masternode list so "unused" means +//! unused network-wide — for operator keys that is a consensus requirement +//! (they are unique across the list), for voting keys a courtesy default. +//! +//! Public-only derivation from the account xpubs; no seed is touched, which +//! is also why only the secp/BLS families are supported here — Ed25519 +//! platform-node keys derive hardened and would need the seed. + +use dashcore::hashes::{hash160, Hash}; + +/// Upper bound on one candidates query. Far above any realistic provider +/// pool, and small enough that `count` can never drive an allocation +/// failure — the FFI re-exports the same value for hosts. +pub const MAX_PROVIDER_KEY_CANDIDATES: u32 = 256; + +use super::list::MasternodeListSummary; +use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::PlatformWallet; +use crate::wallet::provider_key_at_index::ProviderKeyKind; + +/// One wallet provider key, with its network-wide usage. +#[derive(Debug, Clone)] +pub struct ProviderKeyCandidate { + /// Index within the provider pool. + pub index: u32, + /// Modern-serialization public key bytes: 48 for a BLS operator key, + /// 33 for a compressed secp voting key. + pub public_key_bytes: Vec, + /// P2PKH address (voting keys only — BLS keys have no address form). + pub address: Option, + /// proTxHash (wire order) of the masternode-list entry currently using + /// this key, when one does. + pub used_by: Option<[u8; 32]>, +} + +/// Derive the first `count` keys of `kind` and join each against the list. +/// Supports [`ProviderKeyKind::Operator`] (matched against entry operator +/// keys under both serializations) and [`ProviderKeyKind::Voting`] (hash160 +/// matched against entry voting key ids); other kinds are refused — owner +/// keys are immutable and never candidates, platform-node keys need the +/// seed. +pub fn provider_key_candidates( + wallet: &PlatformWallet, + summaries: &[MasternodeListSummary], + kind: ProviderKeyKind, + count: u32, +) -> Result, PlatformWalletError> { + match kind { + ProviderKeyKind::Operator | ProviderKeyKind::Voting => {} + _ => { + return Err(PlatformWalletError::InvalidParameter( + "key candidates are available for operator and voting keys only".to_string(), + )); + } + } + if count > MAX_PROVIDER_KEY_CANDIDATES { + return Err(PlatformWalletError::InvalidParameter(format!( + "at most {MAX_PROVIDER_KEY_CANDIDATES} key candidates can be listed per call, \ + {count} were requested" + ))); + } + + let mut candidates = Vec::with_capacity(count as usize); + for index in 0..count { + let derived = wallet.derive_provider_key_at_index(kind, index, None, false)?; + let used_by = match kind { + ProviderKeyKind::Operator => { + let modern: Option<[u8; 48]> = derived.public_key_bytes.as_slice().try_into().ok(); + let legacy: Option<[u8; 48]> = derived + .legacy_public_key_bytes + .as_deref() + .and_then(|b| b.try_into().ok()); + summaries + .iter() + .find(|entry| { + modern.is_some_and(|k| entry.operator_public_key == k) + || legacy.is_some_and(|k| entry.operator_public_key == k) + }) + .map(|entry| entry.pro_tx_hash) + } + ProviderKeyKind::Voting => { + let key_id = hash160::Hash::hash(&derived.public_key_bytes).to_byte_array(); + summaries + .iter() + .find(|entry| entry.voting_key_id == key_id) + .map(|entry| entry.pro_tx_hash) + } + _ => unreachable!("kind validated above"), + }; + candidates.push(ProviderKeyCandidate { + index, + public_key_bytes: derived.public_key_bytes, + address: derived.address, + used_by, + }); + } + Ok(candidates) +} + +#[cfg(test)] +mod tests { + use super::super::list::test_support::masternode; + use super::super::locator::bls_public_keys; + use super::*; + use crate::test_support::test_platform_wallet_manager; + use crate::wallet::platform_wallet::PlatformWallet; + use std::sync::Arc; + + /// Derivation and the wallet-manager reads are blocking, so the async + /// setup runs on its own runtime and the candidates query runs outside + /// it — the same threading shape the FFI worker gives it. + fn test_wallet() -> Arc { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime"); + let (manager, wallet_id) = runtime.block_on(test_platform_wallet_manager()); + let wallet = manager + .get_wallet_blocking(&wallet_id) + .expect("test wallet"); + // Keep the manager alive alongside the wallet handle. + std::mem::forget(manager); + wallet + } + + #[test] + fn candidates_join_operator_keys_under_both_serializations() { + let wallet = test_wallet(); + + let derived0 = wallet + .derive_provider_key_at_index(ProviderKeyKind::Operator, 0, None, false) + .expect("operator key 0"); + let modern0: [u8; 48] = derived0.public_key_bytes.as_slice().try_into().expect("48"); + let legacy1: [u8; 48] = wallet + .derive_provider_key_at_index(ProviderKeyKind::Operator, 1, None, false) + .expect("operator key 1") + .legacy_public_key_bytes + .expect("operator keys carry a legacy form") + .as_slice() + .try_into() + .expect("48"); + + let mut used_modern = masternode(0x11); + used_modern.operator_public_key = modern0; + let mut used_legacy = masternode(0x22); + used_legacy.operator_public_key = legacy1; + let summaries = vec![used_modern, used_legacy]; + + let candidates = provider_key_candidates(&wallet, &summaries, ProviderKeyKind::Operator, 3) + .expect("candidates"); + assert_eq!(candidates.len(), 3); + assert_eq!( + candidates[0].used_by, + Some([0x11; 32]), + "modern-serialization usage is joined" + ); + assert_eq!( + candidates[1].used_by, + Some([0x22; 32]), + "legacy-serialization usage is joined" + ); + assert_eq!(candidates[2].used_by, None, "an unused key stays eligible"); + assert!( + candidates[0].address.is_none(), + "BLS keys have no address form" + ); + assert_eq!(candidates[0].public_key_bytes.len(), 48); + } + + #[test] + fn candidates_join_voting_keys_by_key_id() { + let wallet = test_wallet(); + + let derived = wallet + .derive_provider_key_at_index(ProviderKeyKind::Voting, 0, None, false) + .expect("voting key 0"); + let key_id = hash160::Hash::hash(&derived.public_key_bytes).to_byte_array(); + + let mut used = masternode(0x33); + used.voting_key_id = key_id; + let summaries = vec![used]; + + let candidates = provider_key_candidates(&wallet, &summaries, ProviderKeyKind::Voting, 2) + .expect("candidates"); + assert_eq!(candidates[0].used_by, Some([0x33; 32])); + assert_eq!(candidates[1].used_by, None); + assert!( + candidates[0].address.is_some(), + "voting keys carry their P2PKH address for display" + ); + assert_eq!(candidates[0].public_key_bytes.len(), 33); + } + + #[test] + fn unsupported_kinds_zero_counts_and_oversized_counts_are_handled() { + let wallet = test_wallet(); + let summaries = vec![masternode(0x44)]; + + for kind in [ProviderKeyKind::Owner, ProviderKeyKind::PlatformNode] { + let err = provider_key_candidates(&wallet, &summaries, kind, 1) + .expect_err("owner / platform-node kinds are refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + let empty = provider_key_candidates(&wallet, &summaries, ProviderKeyKind::Operator, 0) + .expect("zero count is an empty listing"); + assert!(empty.is_empty()); + + let err = provider_key_candidates( + &wallet, + &summaries, + ProviderKeyKind::Operator, + MAX_PROVIDER_KEY_CANDIDATES + 1, + ) + .expect_err("counts above the bound are refused before any allocation"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // Sanity: a valid operator secret's own serializations round + // through the same join the picker uses. + let (basic, legacy) = bls_public_keys(&[7u8; 32]).expect("valid scalar"); + assert_ne!(basic, legacy, "the two serializations differ in flag bits"); + } +} diff --git a/packages/rs-platform-wallet/src/masternode/list.rs b/packages/rs-platform-wallet/src/masternode/list.rs index 41614c029a..c870c49522 100644 --- a/packages/rs-platform-wallet/src/masternode/list.rs +++ b/packages/rs-platform-wallet/src/masternode/list.rs @@ -51,6 +51,13 @@ pub struct MasternodeListSummary { /// Snapshots persisted before this field existed default to `false`; /// that guard reads only live list summaries. pub has_extended_net_info: bool, + /// The entry is version 1 (pre-v19), so `operator_public_key` uses the + /// LEGACY BLS serialization. A kept operator key re-entering a + /// version-2 payload must then be reserialized to the basic scheme — + /// the two serializations of one point differ only in flag bits, so + /// this cannot be inferred from the bytes. Defaults to `false` on + /// pre-field snapshots; the registrar path reads only live summaries. + pub operator_key_is_legacy: bool, } impl MasternodeListSummary { @@ -89,6 +96,7 @@ impl MasternodeListSummary { entry.service_address, dashcore::sml::masternode_list_entry::MasternodeNetInfo::Extended(_) ), + operator_key_is_legacy: entry.version < 2, } } @@ -178,6 +186,7 @@ pub(crate) mod test_support { is_valid: true, is_evonode: false, has_extended_net_info: false, + operator_key_is_legacy: false, } } diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index 6c86b52610..5b455e9d00 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -8,12 +8,17 @@ //! the operator / platform-node keys. Both FFI crates and the withdrawal //! path read through it, so every host renders the same records. +pub mod key_candidates; pub mod list; pub mod locator; pub mod record; pub mod tracked; +pub mod update_registrar; pub mod update_service; +pub use key_candidates::{ + provider_key_candidates, ProviderKeyCandidate, MAX_PROVIDER_KEY_CANDIDATES, +}; pub use list::{find_in_summaries, MasternodeListQuery, MasternodeListSummary}; pub use locator::{ locate_in_summaries, parse_locator_input, parse_secret_for_role, verify_masternode_key, @@ -30,9 +35,14 @@ pub use tracked::{ capabilities_for_roles, snapshot_from_json, snapshot_to_json, MasternodeCapabilities, PlatformKeySnapshot, RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot, }; +pub use update_registrar::{ + execute_masternode_update_registrar, prepare_masternode_update_registrar, + MasternodeUpdateRegistrarParams, OwnerSecret, +}; pub use update_service::{ - execute_masternode_update_service, prepare_masternode_update_service, - MasternodeUpdateServiceParams, + execute_masternode_update_service, execute_masternode_update_service_with_values, + prepare_masternode_update_service, prepare_masternode_update_service_with_values, + MasternodeUpdateServiceParams, UpdateServiceValues, }; use crate::changeset::PlatformWalletPersistence; diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs index 66e179015b..9925b0b741 100644 --- a/packages/rs-platform-wallet/src/masternode/tracked.rs +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -306,6 +306,7 @@ fn list_to_json(list: &MasternodeListSummary) -> Value { "isValid": list.is_valid, "isEvonode": list.is_evonode, "hasExtendedNetInfo": list.has_extended_net_info, + "operatorKeyIsLegacy": list.operator_key_is_legacy, }) } @@ -324,6 +325,7 @@ fn list_from_json(value: &Value) -> Option { // 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), + operator_key_is_legacy: value["operatorKeyIsLegacy"].as_bool().unwrap_or(false), }) } diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs new file mode 100644 index 0000000000..bf1bcfb301 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -0,0 +1,768 @@ +//! ProUpRegTx (provider update registrar) orchestration. +//! +//! Rotates a masternode's operator and/or voting key to fresh wallet keys, +//! authorized by the immutable owner key. The payload commits to the +//! funding inputs (`inputs_hash`) and carries a 65-byte compact recoverable +//! ECDSA signature by the owner key over `base_payload_hash()` — Core's +//! `CHashSigner` convention, pinned by the real testnet vector in +//! dashcore's `provider_update_registrar` tests — so the build order is the +//! same as the update-service path: select and reserve inputs → write +//! `inputs_hash` → compact-sign the payload → ECDSA-sign the inputs → +//! broadcast, riding key-wallet's payload-finalizer seam. +//! +//! Consensus consequence callers must plan for: when the operator key +//! changes, Core RESETS the entry's service fields and PoSe-bans the node +//! until the NEW operator broadcasts a ProUpServTx. Rotating the operator +//! key is therefore a two-stage flow; stage two is the explicit-values +//! update-service in this crate's sibling module. A voting-only (or +//! payout-only) update has no such reset. + +use dashcore::blockdata::script::ScriptBuf; +use dashcore::blockdata::transaction::special_transaction::provider_update_registrar::ProviderUpdateRegistrarPayload; +use dashcore::blockdata::transaction::special_transaction::{ + SpecialTransactionBasePayloadEncodable, TransactionPayload, +}; +use dashcore::bls_sig_utils::BLSPublicKey; +use dashcore::hashes::{hash160, Hash}; +use dashcore::secp256k1::{Message, Secp256k1, SecretKey}; +use dashcore::{Address as DashAddress, Network, PubkeyHash, Txid}; +use key_wallet::wallet::managed_wallet_info::transaction_builder::{ + BuilderError, TransactionBuilder, TransactionSigner, +}; +use zeroize::Zeroizing; + +use super::list::MasternodeListSummary; +use super::locator::p2pkh_script_hash; +use super::update_service::{ + display_hex, fetch_registration_payload, require_standard_payout_script, +}; +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +use crate::spv::SpvRuntime; +use crate::wallet::core::{CoreWallet, SignedCoreTransaction, SEND_FUNDING_SOURCES}; +use crate::wallet::platform_wallet::PlatformWallet; +use crate::wallet::provider_key_at_index::ProviderKeyKind; + +/// What a registrar update lets the caller change. `None` keeps the +/// entry's current value (the payload always carries a full field set, so +/// "keep" means "copy from the live list entry"). +#[derive(Debug, Clone)] +pub struct MasternodeUpdateRegistrarParams { + /// ProRegTx hash of the masternode to update, in WIRE order. + pub pro_tx_hash: [u8; 32], + /// Wallet `ProviderOperatorKeys` index for the NEW operator key, or + /// `None` to keep the current operator key. Changing the operator key + /// PoSe-bans the node with its service fields reset until a + /// ProUpServTx from the new key reactivates it. + pub new_operator_key_index: Option, + /// Wallet `ProviderVotingKeys` index for the NEW voting key, or `None` + /// to keep the current voting key. + pub new_voting_key_index: Option, + /// Owner payout address. Always required: the payload REPLACES the + /// payout script on-chain, so the caller must confirm it explicitly — + /// an empty script is refused outright. + pub payout_address: String, +} + +/// The owner's secp256k1 secret plus whether its public key is the +/// compressed form — the compact-signature header byte encodes it, and a +/// wrong flag makes recovery resolve to a different key id. +pub struct OwnerSecret { + pub secret: Zeroizing<[u8; 32]>, + pub compressed: bool, +} + +/// Build, owner-sign, fund, input-sign, and broadcast a ProUpRegTx — the +/// transaction Core produces for `protx update_registrar`. +pub async fn execute_masternode_update_registrar( + wallet: &PlatformWallet, + spv: &SpvRuntime, + params: MasternodeUpdateRegistrarParams, + owner: OwnerSecret, + signer: &S, +) -> Result { + let signed = prepare_masternode_update_registrar(wallet, spv, params, owner, signer).await?; + wallet.core().broadcast_finalized_transaction(&signed).await +} + +/// Everything [`execute_masternode_update_registrar`] does except the +/// broadcast, for hosts that show the transaction first. The returned +/// transaction holds its funding inputs reserved; broadcast or abandon it. +pub async fn prepare_masternode_update_registrar( + wallet: &PlatformWallet, + spv: &SpvRuntime, + params: MasternodeUpdateRegistrarParams, + owner: OwnerSecret, + signer: &S, +) -> Result { + if params.new_operator_key_index.is_none() && params.new_voting_key_index.is_none() { + return Err(PlatformWalletError::InvalidParameter( + "nothing to rotate: neither a new operator key nor a new voting key was chosen" + .to_string(), + )); + } + + 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) + )) + })?; + + // Rotating the operator key erases the entry's service values, and + // stage two re-asserts a single address — which would downgrade a v3 + // extended-net-info entry's endpoint map. Fail closed, exactly like the + // unban path. A voting-only update touches no service state. + if params.new_operator_key_index.is_some() && entry.has_extended_net_info { + return Err(PlatformWalletError::InvalidParameter( + "this masternode advertises v3 extended network info; rotating its operator key \ + would require re-asserting a single service address and discard its endpoint \ + map, so it cannot be rotated from this wallet yet" + .to_string(), + )); + } + + // The owner key is immutable — set at registration, never rotatable — + // so the ProRegTx's keyIDOwner is the reliable authority to verify the + // supplied secret against (the masternode list does not carry it). + let registration = fetch_registration_payload(wallet, ¶ms.pro_tx_hash).await?; + verify_owner_secret(®istration.owner_key_hash, &owner)?; + + let script_payout = resolve_owner_payout_script(¶ms.payout_address, wallet.network())?; + + // Resolve the payload's full field set: chosen fresh wallet keys where + // the caller rotates, the live entry's values where it keeps. + let operator_public_key = match params.new_operator_key_index { + Some(index) => { + let derived = wallet.derive_provider_key_at_index( + ProviderKeyKind::Operator, + index, + None, + false, + )?; + let bytes: [u8; 48] = derived + .public_key_bytes + .as_slice() + .try_into() + .map_err(|_| { + PlatformWalletError::KeyDerivation( + "derived operator public key is not 48 bytes".to_string(), + ) + })?; + let legacy: Option<[u8; 48]> = derived + .legacy_public_key_bytes + .as_deref() + .and_then(|b| b.try_into().ok()); + ensure_operator_key_unused(&summaries, &bytes, legacy.as_ref())?; + bytes + } + None => normalize_operator_key_to_basic( + &entry.operator_public_key, + entry.operator_key_is_legacy, + )?, + }; + let voting_key_hash = match params.new_voting_key_index { + Some(index) => { + let derived = + wallet.derive_provider_key_at_index(ProviderKeyKind::Voting, index, None, false)?; + hash160::Hash::hash(&derived.public_key_bytes).to_byte_array() + } + None => entry.voting_key_id, + }; + + // Consensus rejects a payout paid to the owner key or the payload's + // (final) voting key (`bad-protx-payee-reuse`) — refuse before funding. + ensure_payout_not_reusing_keys( + &script_payout, + ®istration.owner_key_hash, + &voting_key_hash, + )?; + + let placeholder = ProviderUpdateRegistrarPayload::new( + Txid::from_byte_array(params.pro_tx_hash), + 0, // provider_mode — 0 is the only defined mode + BLSPublicKey::from(operator_public_key), + PubkeyHash::from_byte_array(voting_key_hash), + script_payout, + dashcore::hash_types::InputsHash::all_zeros(), + Vec::new(), + ); + + build_sign_update_registrar(wallet.core(), placeholder, owner, signer).await +} + +/// Refuse an owner secret whose public key hash does not match the +/// ProRegTx's immutable `keyIDOwner`, before any signing or network work. +pub(crate) fn verify_owner_secret( + expected_owner_key_hash: &PubkeyHash, + owner: &OwnerSecret, +) -> Result<(), PlatformWalletError> { + let secp = Secp256k1::new(); + let secret = SecretKey::from_byte_array(&owner.secret).map_err(|_| { + PlatformWalletError::InvalidParameter( + "the owner key is not a valid secp256k1 private key".to_string(), + ) + })?; + let public = secret.public_key(&secp); + let serialized: Vec = if owner.compressed { + public.serialize().to_vec() + } else { + public.serialize_uncompressed().to_vec() + }; + let hash = hash160::Hash::hash(&serialized); + if hash.to_byte_array() != expected_owner_key_hash.to_byte_array() { + return Err(PlatformWalletError::InvalidParameter( + "the owner key does not match this masternode's registered owner key".to_string(), + )); + } + Ok(()) +} + +/// The payout rule for a registrar update: the payload replaces the owner +/// payout script on-chain, so the address is always required and confirmed +/// by the caller — never defaulted, never empty. +pub(crate) fn resolve_owner_payout_script( + payout_address: &str, + network: Network, +) -> Result { + let trimmed = payout_address.trim(); + if trimmed.is_empty() { + return Err(PlatformWalletError::InvalidParameter( + "the payout address is required: the update replaces the payout script on-chain, \ + and an empty script would clear it" + .to_string(), + )); + } + let address = trimmed + .parse::>() + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "payout address is not a valid Dash address: {e}" + )) + })? + .require_network(network) + .map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "payout address is for another network: {e}" + )) + })?; + let script = address.script_pubkey(); + require_standard_payout_script(&script)?; + Ok(script) +} + +/// Refuse a candidate operator key already registered to any masternode — +/// operator keys are consensus-unique across the whole list, so a duplicate +/// would make the ProUpRegTx invalid. The list may hold either +/// serialization of a key, so both forms are checked. +pub(crate) fn ensure_operator_key_unused( + summaries: &[MasternodeListSummary], + candidate: &[u8; 48], + candidate_legacy: Option<&[u8; 48]>, +) -> Result<(), PlatformWalletError> { + let clash = summaries.iter().find(|entry| { + entry.operator_public_key == *candidate + || candidate_legacy.is_some_and(|legacy| entry.operator_public_key == *legacy) + }); + if let Some(entry) = clash { + return Err(PlatformWalletError::InvalidParameter(format!( + "the chosen operator key is already used by masternode {} — operator keys must \ + be unique; pick an unused key", + display_hex(&entry.pro_tx_hash) + ))); + } + Ok(()) +} + +/// Consensus rejects a P2PKH payout paid to the owner key or the payload's +/// final voting key (`bad-protx-payee-reuse`). Both hashes are known before +/// funding — the immutable owner hash from the ProRegTx, the voting hash +/// from the payload being built — so a doomed transaction is refused here. +/// (P2SH payouts carry a script hash, not a key id, and cannot collide.) +pub(crate) fn ensure_payout_not_reusing_keys( + script_payout: &ScriptBuf, + owner_key_hash: &PubkeyHash, + final_voting_key_hash: &[u8; 20], +) -> Result<(), PlatformWalletError> { + let Some(payee) = p2pkh_script_hash(script_payout.as_bytes()) else { + return Ok(()); + }; + if payee == owner_key_hash.to_byte_array() { + return Err(PlatformWalletError::InvalidParameter( + "the payout address is the masternode's owner address — consensus rejects paying \ + the payout to the owner key; pick a different payout address" + .to_string(), + )); + } + if payee == *final_voting_key_hash { + return Err(PlatformWalletError::InvalidParameter( + "the payout address is the masternode's voting address — consensus rejects paying \ + the payout to the voting key; pick a different payout address" + .to_string(), + )); + } + Ok(()) +} + +/// A kept (not rotated) operator key re-enters a version-2 payload, which +/// Core deserializes under the BASIC scheme — but a version-1 (pre-v19) +/// list entry carries the key in the LEGACY serialization. The two +/// serializations of one point differ only in flag bits (legacy bytes can +/// even parse "successfully" under the basic scheme as a different +/// reading), so the entry's version — not the bytes — decides: a legacy +/// key is parsed under Legacy and reserialized to basic; a basic key is +/// validated and passed through. +pub(crate) fn normalize_operator_key_to_basic( + bytes: &[u8; 48], + is_legacy: bool, +) -> Result<[u8; 48], PlatformWalletError> { + use dashcore::blsful::{Bls12381G2Impl, PublicKey as BlsPubKey, SerializationFormat}; + let format = if is_legacy { + SerializationFormat::Legacy + } else { + SerializationFormat::Modern + }; + let key = BlsPubKey::::from_bytes_with_mode(bytes, format).map_err(|_| { + PlatformWalletError::InvalidParameter( + "the masternode's current operator key could not be parsed in the entry's BLS \ + serialization" + .to_string(), + ) + })?; + if !is_legacy { + return Ok(*bytes); + } + key.to_bytes().as_slice().try_into().map_err(|_| { + PlatformWalletError::KeyDerivation("reserialized operator key is not 48 bytes".to_string()) + }) +} + +/// Compact recoverable ECDSA over `base_payload_hash`, in Core's +/// `CHashSigner` form: `[27 + recovery_id + (compressed ? 4 : 0)] ‖ r ‖ s` +/// (65 bytes) — the hash is signed directly, with no message prefix. The +/// real testnet ProUpRegTx vector's signature starts `0x1f` = 31 = +/// 27 + 0 + 4, confirming the convention. +pub(crate) fn owner_compact_signature( + payload: &ProviderUpdateRegistrarPayload, + owner: &OwnerSecret, +) -> Result, PlatformWalletError> { + let secp = Secp256k1::new(); + let secret = SecretKey::from_byte_array(&owner.secret).map_err(|_| { + PlatformWalletError::InvalidParameter( + "the owner key is not a valid secp256k1 private key".to_string(), + ) + })?; + let digest = payload.base_payload_hash().to_byte_array(); + let message = Message::from_digest(digest); + let recoverable = secp.sign_ecdsa_recoverable(&message, &secret); + let (recovery_id, compact) = recoverable.serialize_compact(); + let mut signature = Vec::with_capacity(65); + signature.push(27 + i32::from(recovery_id) as u8 + if owner.compressed { 4 } else { 0 }); + signature.extend_from_slice(&compact); + Ok(signature) +} + +/// Fund and finalize the ProUpRegTx: input selection reserves the funding +/// inputs, the payload finalizer writes `inputs_hash` and the owner's +/// compact signature, 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_registrar( + core: &CoreWallet, + placeholder: ProviderUpdateRegistrarPayload, + owner: OwnerSecret, + signer: &S, +) -> Result +where + B: TransactionBroadcaster + ?Sized, + S: TransactionSigner + ?Sized + Sync, +{ + let builder = TransactionBuilder::new() + .set_special_payload(TransactionPayload::ProviderUpdateRegistrarPayloadType( + placeholder, + )) + .set_payload_finalizer(move |unsigned| { + let Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(placeholder)) = + &unsigned.special_transaction_payload + else { + return Err(BuilderError::InvalidData( + "the ProUpRegTx placeholder payload is missing from the assembled \ + transaction" + .into(), + )); + }; + let mut finalized = placeholder.clone(); + finalized.inputs_hash = unsigned.hash_inputs(); + finalized.payload_sig = owner_compact_signature(&finalized, &owner) + .map_err(|e| BuilderError::SigningFailed(e.to_string()))?; + Ok(TransactionPayload::ProviderUpdateRegistrarPayloadType( + finalized, + )) + }); + + core.finalize_transaction(builder, &SEND_FUNDING_SOURCES, 0, signer) + .await +} + +#[cfg(test)] +mod tests { + use super::super::list::test_support::masternode; + use super::*; + use crate::broadcaster::BroadcastError; + use crate::test_support::funded_wallet_manager; + use dashcore::hash_types::InputsHash; + use dashcore::secp256k1::ecdsa::{RecoverableSignature, RecoveryId}; + use dashcore::Transaction; + use key_wallet::account::StandardAccountType; + use std::str::FromStr; + use std::sync::{Arc, Mutex}; + + /// A fixed valid secp256k1 scalar so the test owner keypair is + /// deterministic. + const OWNER_SECRET: [u8; 32] = [7u8; 32]; + + fn owner() -> OwnerSecret { + OwnerSecret { + secret: Zeroizing::new(OWNER_SECRET), + compressed: true, + } + } + + fn owner_key_hash() -> PubkeyHash { + let secp = Secp256k1::new(); + let secret = SecretKey::from_byte_array(&OWNER_SECRET).expect("valid scalar"); + let public = secret.public_key(&secp); + PubkeyHash::from_byte_array(hash160::Hash::hash(&public.serialize()).to_byte_array()) + } + + #[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 payload-hash convention, pinned against the real testnet + /// ProUpRegTx vector embedded in dashcore's own payload tests. + #[test] + fn base_payload_hash_matches_the_known_testnet_vector() { + let operator = <[u8; 48]>::try_from( + hex::decode( + "139b654f0b1c031e1cf2b934c2d895178875cfe7c6a4f6758f02bc66eea7fc292d0040701acbe31f5e14a911cb061a2f", + ) + .expect("hex") + .as_slice(), + ) + .expect("48 bytes"); + let voting = <[u8; 20]>::try_from( + hex::decode("6cc4a7bb877a80c11ae06b988d98305773f93b98") + .expect("hex") + .as_slice(), + ) + .expect("20 bytes"); + let payout_hash = <[u8; 20]>::try_from( + hex::decode("56bcf3cac49235537d6ce0fb3214d8850a6db777") + .expect("hex") + .as_slice(), + ) + .expect("20 bytes"); + + let payload = ProviderUpdateRegistrarPayload { + version: 1, + pro_tx_hash: Txid::from_str( + "3dbb7de94e219e8f7eaea4f3c01cf97d77372e10152734c1959f17302369aa49", + ) + .expect("txid"), + provider_mode: 0, + operator_public_key: BLSPublicKey::from(operator), + voting_key_hash: PubkeyHash::from_byte_array(voting), + script_payout: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(payout_hash)), + inputs_hash: InputsHash::from_str( + "cf2b940faa8c46c7981f5bd082e5409bf08cffe3bccfa04093eb152f7a857f2d", + ) + .expect("inputs hash"), + payload_sig: Vec::new(), + }; + assert_eq!( + format!("{:x}", payload.base_payload_hash()), + "85deffc85d2304f0305356e1dc8d02eecdb3220576abb370bc67be446c854296", + "payload hash must match the vector dashcore pins" + ); + } + + /// The owner signature is Core's CHashSigner form: 65 bytes, header + /// 27 + recovery_id (+4 compressed), recovering to the owner key. + #[test] + fn owner_signature_is_compact_recoverable_over_the_payload_hash() { + let payload = ProviderUpdateRegistrarPayload { + version: 2, + pro_tx_hash: Txid::all_zeros(), + provider_mode: 0, + operator_public_key: BLSPublicKey::from([4u8; 48]), + voting_key_hash: PubkeyHash::from_byte_array([3u8; 20]), + script_payout: ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([5u8; 20])), + inputs_hash: InputsHash::all_zeros(), + payload_sig: Vec::new(), + }; + let signature = owner_compact_signature(&payload, &owner()).expect("signs"); + assert_eq!(signature.len(), 65); + assert!( + (31..=34).contains(&signature[0]), + "compressed-key header byte, got {}", + signature[0] + ); + + // Recover and compare against the owner key id — the check Core's + // CheckHashSig performs on validation. + let secp = Secp256k1::new(); + let recovery_id = + RecoveryId::try_from(i32::from(signature[0] - 27 - 4)).expect("recovery id"); + let recoverable = + RecoverableSignature::from_compact(&signature[1..], recovery_id).expect("compact body"); + let digest = Message::from_digest(payload.base_payload_hash().to_byte_array()); + let recovered = secp.recover_ecdsa(&digest, &recoverable).expect("recovers"); + assert_eq!( + hash160::Hash::hash(&recovered.serialize()).to_byte_array(), + owner_key_hash().to_byte_array(), + "the signature must recover to the owner key id" + ); + } + + #[test] + fn owner_secret_is_verified_against_the_registered_key_id() { + verify_owner_secret(&owner_key_hash(), &owner()).expect("matching owner accepted"); + + let err = verify_owner_secret(&PubkeyHash::from_byte_array([9u8; 20]), &owner()) + .expect_err("a different owner key id must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let invalid = OwnerSecret { + secret: Zeroizing::new([0u8; 32]), + compressed: true, + }; + let err = verify_owner_secret(&owner_key_hash(), &invalid) + .expect_err("an invalid scalar must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + #[test] + fn payout_address_is_required_and_network_checked() { + let err = resolve_owner_payout_script("", Network::Testnet) + .expect_err("an empty payout must be refused, never cleared"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let testnet = DashAddress::dummy(Network::Testnet, 3); + let script = resolve_owner_payout_script(&testnet.to_string(), Network::Testnet) + .expect("valid address accepted"); + assert_eq!(script, testnet.script_pubkey()); + + let mainnet = DashAddress::dummy(Network::Mainnet, 3).to_string(); + let err = resolve_owner_payout_script(&mainnet, Network::Testnet) + .expect_err("network mismatch must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + /// Operator keys are consensus-unique across the list — a candidate in + /// use (under either serialization) must be refused before signing. + #[test] + fn used_operator_keys_are_refused() { + let mut entry = masternode(0x11); + entry.operator_public_key = [0xAA; 48]; + let summaries = vec![entry]; + + let err = ensure_operator_key_unused(&summaries, &[0xAA; 48], None) + .expect_err("modern-serialization clash refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let err = ensure_operator_key_unused(&summaries, &[0xBB; 48], Some(&[0xAA; 48])) + .expect_err("legacy-serialization clash refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + ensure_operator_key_unused(&summaries, &[0xBB; 48], Some(&[0xCC; 48])) + .expect("an unused key passes"); + } + + #[tokio::test] + async fn builds_signs_and_broadcasts_a_pro_up_reg_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 placeholder = ProviderUpdateRegistrarPayload::new( + Txid::from_byte_array([0x22; 32]), + 0, + BLSPublicKey::from([4u8; 48]), + PubkeyHash::from_byte_array([3u8; 20]), + ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([5u8; 20])), + InputsHash::all_zeros(), + Vec::new(), + ); + + let prepared = build_sign_update_registrar(&core, placeholder, owner(), &signer) + .await + .expect("registrar update builds and signs"); + assert!( + broadcaster + .sent + .lock() + .expect("broadcaster lock") + .is_empty(), + "preparing must not broadcast" + ); + + let txid = core + .broadcast_finalized_transaction(&prepared) + .await + .expect("prepared transaction broadcasts"); + + let sent = broadcaster.sent.lock().expect("broadcaster lock"); + assert_eq!(sent.len(), 1); + 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())); + + let Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(payload)) = + &tx.special_transaction_payload + else { + panic!("the broadcast transaction must carry the ProUpRegTx payload"); + }; + assert_eq!(payload.inputs_hash, tx.hash_inputs()); + assert_eq!( + payload.version, + ProviderUpdateRegistrarPayload::CURRENT_VERSION + ); + assert_eq!(payload.payload_sig.len(), 65); + + // The owner signature recovers over the finished payload hash. + let secp = Secp256k1::new(); + let recovery_id = + RecoveryId::try_from(i32::from(payload.payload_sig[0] - 27 - 4)).expect("recid"); + let recoverable = + RecoverableSignature::from_compact(&payload.payload_sig[1..], recovery_id) + .expect("compact body"); + let digest = Message::from_digest(payload.base_payload_hash().to_byte_array()); + let recovered = secp.recover_ecdsa(&digest, &recoverable).expect("recovers"); + assert_eq!( + hash160::Hash::hash(&recovered.serialize()).to_byte_array(), + owner_key_hash().to_byte_array() + ); + } +} + +#[cfg(test)] +mod review_tests { + use super::super::locator::bls_public_keys; + use super::*; + use dashcore::secp256k1::PublicKey as SecpPublicKey; + + /// Consensus accepts only P2PKH / P2SH payouts (`bad-protx-payee`): a + /// witness-program address must be refused before funding. + #[test] + fn witness_payout_addresses_are_refused() { + let secp = Secp256k1::new(); + let secret = SecretKey::from_byte_array(&[7u8; 32]).expect("valid scalar"); + let public = dashcore::PublicKey::new(SecpPublicKey::from_secret_key(&secp, &secret)); + let witness = DashAddress::p2wpkh(&public, Network::Testnet).expect("p2wpkh address"); + + let err = resolve_owner_payout_script(&witness.to_string(), Network::Testnet) + .expect_err("a witness payout must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // The operator-payout resolver in the update-service path applies + // the same restriction to a non-empty payout. + let err = super::super::update_service::resolve_operator_payout_script( + 500, + Some(&witness.to_string()), + Network::Testnet, + ) + .expect_err("a witness operator payout must be refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // P2SH remains accepted. + let p2sh = DashAddress::p2sh( + &ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([9; 20])), + Network::Testnet, + ) + .expect("p2sh address"); + resolve_owner_payout_script(&p2sh.to_string(), Network::Testnet) + .expect("a P2SH payout is accepted"); + } + + /// Consensus rejects a payout paid to the owner or final voting key + /// (`bad-protx-payee-reuse`). + #[test] + fn payouts_reusing_owner_or_voting_keys_are_refused() { + let owner = PubkeyHash::from_byte_array([0x11; 20]); + let voting = [0x22u8; 20]; + + let owner_payout = ScriptBuf::new_p2pkh(&owner); + let err = ensure_payout_not_reusing_keys(&owner_payout, &owner, &voting) + .expect_err("owner-address payout refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let voting_payout = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(voting)); + let err = ensure_payout_not_reusing_keys(&voting_payout, &owner, &voting) + .expect_err("voting-address payout refused — including a newly selected candidate"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let other = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0x33; 20])); + ensure_payout_not_reusing_keys(&other, &owner, &voting) + .expect("an unrelated payout passes"); + + // P2SH carries a script hash, not a key id — never a collision. + let p2sh = ScriptBuf::new_p2sh(&dashcore::ScriptHash::from_byte_array([0x11; 20])); + ensure_payout_not_reusing_keys(&p2sh, &owner, &voting) + .expect("a P2SH payout cannot reuse a key id"); + } + + /// A kept operator key from a version-1 (legacy-serialized) entry is + /// reserialized to the basic scheme a v2 payload requires; a v2 entry's + /// key passes through; garbage is refused. The entry version — not the + /// bytes — picks the scheme: legacy bytes also "parse" under basic (the + /// serializations differ only in flag bits), so sniffing is unsound. + #[test] + fn kept_operator_keys_are_normalized_to_basic() { + let (basic, legacy) = bls_public_keys(&[7u8; 32]).expect("valid scalar"); + + assert_eq!( + normalize_operator_key_to_basic(&basic, false).expect("basic passes through"), + basic + ); + assert_eq!( + normalize_operator_key_to_basic(&legacy, true).expect("legacy is reserialized"), + basic, + "the same G1 point re-emerges in basic serialization" + ); + let err = normalize_operator_key_to_basic(&[0xFF; 48], false) + .expect_err("invalid basic bytes are refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + let err = normalize_operator_key_to_basic(&[0xFF; 48], true) + .expect_err("invalid legacy bytes are refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } +} diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 90a66fda51..e2ebdb7a99 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -16,7 +16,9 @@ //! 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_registration::{ + ProviderMasternodeType, ProviderRegistrationPayload, +}; use dashcore::blockdata::transaction::special_transaction::provider_update_service::ProviderUpdateServicePayload; use dashcore::blockdata::transaction::special_transaction::{ SpecialTransactionBasePayloadEncodable, TransactionPayload, @@ -142,6 +144,182 @@ async fn fetch_operator_reward( wallet: &PlatformWallet, pro_tx_hash: &[u8; 32], ) -> Result { + fetch_registration_payload(wallet, pro_tx_hash) + .await + .map(|registration| registration.operator_reward) +} + +/// The explicit service values a stage-two reactivation re-asserts — +/// captured from the list entry BEFORE a registrar update erased them, +/// since a ProUpRegTx that changes the operator key resets the entry's +/// service fields. For an evonode all three platform values are required; +/// for a regular masternode all must be `None`. +#[derive(Debug, Clone)] +pub struct UpdateServiceValues { + /// Core P2P endpoint as `"ip:port"`. + pub service_address: String, + pub platform_node_id: Option<[u8; 20]>, + pub platform_p2p_port: Option, + pub platform_http_port: Option, +} + +/// [`execute_masternode_update_service`] with caller-supplied service +/// values instead of copying the live list entry — the reactivation half of +/// an operator-key rotation, whose ProUpRegTx left the entry banned with +/// its service fields reset (so there is nothing to copy). +/// +/// Every other preflight is unchanged: the entry must still exist, the +/// operator secret must match ITS operator key (after a rotation confirms, +/// that is the new wallet key), and the payout rule reads the ProRegTx's +/// `operatorReward`. The v3 extended-net-info guard also still applies — +/// naturally passing post-reset, since a reset entry no longer advertises +/// an endpoint map. +pub async fn execute_masternode_update_service_with_values( + wallet: &PlatformWallet, + spv: &SpvRuntime, + pro_tx_hash: [u8; 32], + values: UpdateServiceValues, + operator_payout_address: Option, + operator_secret: Zeroizing<[u8; 32]>, + signer: &S, +) -> Result { + let signed = prepare_masternode_update_service_with_values( + wallet, + spv, + pro_tx_hash, + values, + operator_payout_address, + operator_secret, + signer, + ) + .await?; + wallet.core().broadcast_finalized_transaction(&signed).await +} + +/// Prepare-only sibling of +/// [`execute_masternode_update_service_with_values`]; ownership contract as +/// [`prepare_masternode_update_service`]. +pub async fn prepare_masternode_update_service_with_values( + wallet: &PlatformWallet, + spv: &SpvRuntime, + pro_tx_hash: [u8; 32], + values: UpdateServiceValues, + operator_payout_address: Option, + 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 == pro_tx_hash) + .ok_or_else(|| { + PlatformWalletError::InvalidParameter(format!( + "masternode {} is not in the masternode list", + display_hex(&pro_tx_hash) + )) + })?; + + verify_operator_secret(&entry.operator_public_key, &operator_secret)?; + + let operator_reward = fetch_operator_reward(wallet, &pro_tx_hash).await?; + let script_payout = resolve_operator_payout_script( + operator_reward, + operator_payout_address.as_deref(), + wallet.network(), + )?; + + let placeholder = + prepare_update_service_placeholder_from_values(entry, &values, script_payout)?; + + build_sign_update_service(wallet.core(), placeholder, operator_secret, signer).await +} + +/// Build the placeholder payload from caller-supplied values. The same +/// evonode/regular gating as the entry-copy path: `mn_type` set explicitly +/// so the serializer cannot silently drop the platform triplet, all three +/// platform values required for an evonode and forbidden for a regular +/// masternode — and the same v3 extended-net-info refusal, which a +/// post-reset entry passes naturally. +pub(crate) fn prepare_update_service_placeholder_from_values( + entry: &MasternodeListSummary, + values: &UpdateServiceValues, + script_payout: ScriptBuf, +) -> Result { + 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: SocketAddr = values.service_address.parse().map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "service address is not a valid ip:port: {e}" + )) + })?; + let (ip_address, port) = service_payload_fields(service); + + let (mn_type, platform_node_id, platform_p2p_port, platform_http_port) = if entry.is_evonode { + let (Some(node_id), Some(p2p), Some(http)) = ( + values.platform_node_id, + values.platform_p2p_port, + values.platform_http_port, + ) else { + return Err(PlatformWalletError::InvalidParameter( + "an evonode payload requires the platform node id, P2P port and HTTP port" + .to_string(), + )); + }; + ( + Some(ProviderMasternodeType::HighPerformance as u16), + Some(PlatformNodeId::from_byte_array(node_id)), + Some(p2p), + Some(http), + ) + } else { + if values.platform_node_id.is_some() + || values.platform_p2p_port.is_some() + || values.platform_http_port.is_some() + { + return Err(PlatformWalletError::InvalidParameter( + "platform values were 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]), + )) +} + +/// Fetch the masternode's ProRegTx via DAPI Core and return its payload, +/// txid-bound (see [`registration_payload_from_fetched`] for why the +/// binding matters). Shared by the payout rule here and the registrar +/// update's owner-key verification — the ProRegTx is the one place the +/// immutable `keyIDOwner` lives. +pub(crate) async fn fetch_registration_payload( + wallet: &PlatformWallet, + pro_tx_hash: &[u8; 32], +) -> Result { let display = display_hex(pro_tx_hash); let fetched = wallet .sdk() @@ -154,24 +332,17 @@ async fn fetch_operator_reward( })? .ok_or_else(|| { PlatformWalletError::InvalidParameter(format!( - "registration transaction {display} was not found; cannot determine the \ - operator reward" + "registration transaction {display} was not found" )) })?; - operator_reward_from_registration(pro_tx_hash, &fetched.transaction) + registration_payload_from_fetched(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( +/// Txid-bind and unwrap a fetched registration transaction's payload. +pub(crate) fn registration_payload_from_fetched( pro_tx_hash: &[u8; 32], - transaction: &dashcore::Transaction, -) -> Result { + transaction: dashcore::Transaction, +) -> Result { let expected = Txid::from_byte_array(*pro_tx_hash); let actual = transaction.txid(); if actual != expected { @@ -180,16 +351,31 @@ pub(crate) fn operator_reward_from_registration( {expected}" ))); } - match &transaction.special_transaction_payload { - Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => { - Ok(registration.operator_reward) - } + match transaction.special_transaction_payload { + Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) => Ok(registration), _ => Err(PlatformWalletError::InvalidParameter(format!( "transaction {expected} is not a provider registration transaction" ))), } } +/// Consensus accepts only P2PKH and P2SH payout scripts in provider +/// transactions (`bad-protx-payee`) — anything else (a witness program, +/// say) would fund and sign a transaction the mempool refuses. +pub(crate) fn require_standard_payout_script( + script: &dashcore::blockdata::script::Script, +) -> Result<(), PlatformWalletError> { + if script.is_p2pkh() || script.is_p2sh() { + Ok(()) + } else { + Err(PlatformWalletError::InvalidParameter( + "the payout address must be a standard P2PKH or P2SH address — consensus rejects \ + every other payout script type" + .to_string(), + )) + } +} + /// 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 @@ -229,7 +415,9 @@ pub(crate) fn resolve_operator_payout_script( "operator payout address is for another network: {e}" )) })?; - Ok(address.script_pubkey()) + let script = address.script_pubkey(); + require_standard_payout_script(&script)?; + Ok(script) } } } @@ -419,7 +607,7 @@ where .await } -fn display_hex(pro_tx_hash: &[u8; 32]) -> String { +pub(crate) fn display_hex(pro_tx_hash: &[u8; 32]) -> String { let mut display = *pro_tx_hash; display.reverse(); hex::encode(display) @@ -632,11 +820,11 @@ mod tests { let transaction = registration_transaction(500); let matching = transaction.txid().to_byte_array(); - let reward = operator_reward_from_registration(&matching, &transaction) + let registration = registration_payload_from_fetched(&matching, transaction.clone()) .expect("a matching registration transaction is accepted"); - assert_eq!(reward, 500); + assert_eq!(registration.operator_reward, 500); - let err = operator_reward_from_registration(&[0x99; 32], &transaction) + let err = registration_payload_from_fetched(&[0x99; 32], transaction) .expect_err("a transaction that does not hash to the request must be refused"); assert!(matches!(err, PlatformWalletError::InvalidIdentityData(_))); @@ -644,7 +832,7 @@ 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_payload_from_fetched(&plain_txid, not_registration) .expect_err("a non-registration transaction must be refused"); assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } @@ -658,6 +846,73 @@ mod tests { assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + /// Stage two of a rotation supplies values explicitly — the entry's own + /// service state was reset by the registrar update, so nothing is copied. + #[test] + fn values_placeholder_builds_from_supplied_values_not_the_entry() { + let mut entry = operator_entry(0x66, true); + // Post-registrar-reset shape: no service address on the entry. + entry.service_address = None; + let values = UpdateServiceValues { + service_address: "203.0.113.66:9999".to_string(), + platform_node_id: Some([0x77; 20]), + platform_p2p_port: Some(26656), + platform_http_port: Some(443), + }; + let payload = + prepare_update_service_placeholder_from_values(&entry, &values, ScriptBuf::new()) + .expect("values placeholder"); + assert_eq!(payload.port, 9999); + assert_eq!( + payload.mn_type, + Some(ProviderMasternodeType::HighPerformance as u16) + ); + assert_eq!( + payload.platform_node_id, + Some(PlatformNodeId::from_byte_array([0x77; 20])) + ); + assert_eq!(payload.platform_p2p_port, Some(26656)); + assert_eq!(payload.platform_http_port, Some(443)); + + let incomplete = UpdateServiceValues { + platform_p2p_port: None, + ..values.clone() + }; + let err = + prepare_update_service_placeholder_from_values(&entry, &incomplete, ScriptBuf::new()) + .expect_err("an evonode needs the full platform triplet"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let mut regular = operator_entry(0x67, false); + regular.service_address = None; + let err = + prepare_update_service_placeholder_from_values(®ular, &values, ScriptBuf::new()) + .expect_err("platform values on a regular masternode are refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let plain = UpdateServiceValues { + service_address: "203.0.113.67:9999".to_string(), + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + }; + let payload = + prepare_update_service_placeholder_from_values(®ular, &plain, ScriptBuf::new()) + .expect("regular values placeholder"); + assert_eq!( + payload.mn_type, + Some(ProviderMasternodeType::Regular as u16) + ); + assert_eq!(payload.platform_node_id, None); + + let mut extended = operator_entry(0x68, true); + extended.has_extended_net_info = true; + let err = + prepare_update_service_placeholder_from_values(&extended, &values, ScriptBuf::new()) + .expect_err("a live extended entry is still refused"); + 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) = diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift new file mode 100644 index 0000000000..a61d2b8eb2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift @@ -0,0 +1,459 @@ +import DashSDKFFI +import Foundation + +/// Which provider-key family a rotation key picker lists. Raw values are +/// the account-type tags every provider-key FFI uses. +public enum RotationKeyKind: UInt8, Sendable { + case operatorBLS = 10 + case votingECDSA = 8 +} + +/// One wallet provider key with its network-wide usage — a rotation +/// key-picker row. `usedByProTxHash` (wire order) names the masternode-list +/// entry currently using the key; `nil` means unused network-wide, which +/// for operator keys is a consensus requirement of a ProUpRegTx. +public struct ProviderKeyCandidate: Sendable { + public let index: UInt32 + /// Modern-serialization public key bytes (48 BLS operator, 33 secp voting). + public let publicKey: Data + /// P2PKH address (voting keys only). + public let address: String? + public let usedByProTxHash: Data? +} + +/// A zeroed 32-byte tuple for txid out-params. +private func zeroTxidTuple() -> ( + 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) +} + +/// Rust reads C strings up to the first NUL, so an embedded U+0000 would +/// silently truncate the value — the transaction would then use a different +/// payout/key/service than the caller supplied. Matches the guard the other +/// wrappers apply (`ManagedPlatformAddressWallet`, `Mnemonic`). +private func requireNoEmbeddedNul(_ value: String, _ label: String) throws { + guard !value.utf8.contains(0) else { + throw PlatformWalletError.invalidParameter( + "\(label) contains an embedded NUL character") + } +} + +extension PlatformWalletManager { + + // MARK: - Key candidates + + /// The wallet's first `count` provider keys of `kind`, each joined + /// against the live masternode list. Throws + /// `.masternodeListUnavailable` before the list has synced — "unused" + /// cannot be asserted without it. The FFI blocks (derivation + list + /// join), so it runs on a detached task. + public func providerKeyCandidates( + walletId: Data, + kind: RotationKeyKind, + count: UInt32 = 20 + ) async throws -> [ProviderKeyCandidate] { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32, + count <= UInt32(PLATFORM_WALLET_PROVIDER_KEY_CANDIDATES_MAX) + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, wallet id not 32 bytes, or count above the candidates maximum") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> [ProviderKeyCandidate] in + var outEntries: UnsafeMutablePointer? + var outCount: UInt = 0 + let ffiResult = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + platform_wallet_manager_provider_key_candidates( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + kind.rawValue, + count, + &outEntries, + &outCount) + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + guard let entries = outEntries, outCount > 0 else { return [] } + defer { platform_wallet_manager_free_provider_key_candidates(entries, outCount) } + return (0.. 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") + } + try requireNoEmbeddedNul(payoutAddress, "payout address") + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> Data in + let resolver = MnemonicResolver() + var txidTuple = zeroTxidTuple() + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + payoutAddress.withCString { cPayout in + platform_wallet_manager_masternode_update_registrar( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ownerKeyIndex, + newOperatorKeyIndex != nil, newOperatorKeyIndex ?? 0, + newVotingKeyIndex != nil, newVotingKeyIndex ?? 0, + cPayout, + resolver.handle, + &txidTuple) + } + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } + }.value + } + + /// Prepare-only sibling of `masternodeUpdateRegistrar` for the + /// review-before-broadcast step; ownership matches + /// `masternodePrepareUpdateService`. + public func masternodePrepareUpdateRegistrar( + walletId: Data, + proTxHash: Data, + ownerKeyIndex: UInt32, + newOperatorKeyIndex: UInt32?, + newVotingKeyIndex: UInt32?, + payoutAddress: String + ) async throws -> FinalizedCoreTransaction { + 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") + } + try requireNoEmbeddedNul(payoutAddress, "payout address") + let handle = self.handle + let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let resolver = MnemonicResolver() + var outHandle: Handle = NULL_HANDLE + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + payoutAddress.withCString { cPayout in + platform_wallet_manager_masternode_prepare_update_registrar( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ownerKeyIndex, + newOperatorKeyIndex != nil, newOperatorKeyIndex ?? 0, + newVotingKeyIndex != nil, newVotingKeyIndex ?? 0, + cPayout, + resolver.handle, + &outHandle) + } + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outHandle + }.value + return try FinalizedCoreTransaction(handle: transactionHandle) + } + + /// `masternodeUpdateRegistrar` for a TRACKED masternode: the owner key + /// is the host-vaulted key text (WIF or 64-char hex); the fee and the + /// new keys still come from `walletId`. + public func trackedMasternodeUpdateRegistrar( + walletId: Data, + proTxHash: Data, + ownerKey: String, + newOperatorKeyIndex: UInt32?, + newVotingKeyIndex: UInt32?, + payoutAddress: String + ) 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") + } + try requireNoEmbeddedNul(ownerKey, "owner key") + try requireNoEmbeddedNul(payoutAddress, "payout address") + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> Data in + let resolver = MnemonicResolver() + var txidTuple = zeroTxidTuple() + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + ownerKey.withCString { cKey in + payoutAddress.withCString { cPayout in + platform_wallet_manager_tracked_masternode_update_registrar( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + newOperatorKeyIndex != nil, newOperatorKeyIndex ?? 0, + newVotingKeyIndex != nil, newVotingKeyIndex ?? 0, + cPayout, + resolver.handle, + &txidTuple) + } + } + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } + }.value + } + + /// Prepare-only sibling of `trackedMasternodeUpdateRegistrar`. + public func trackedMasternodePrepareUpdateRegistrar( + walletId: Data, + proTxHash: Data, + ownerKey: String, + newOperatorKeyIndex: UInt32?, + newVotingKeyIndex: UInt32?, + payoutAddress: String + ) async throws -> FinalizedCoreTransaction { + 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") + } + try requireNoEmbeddedNul(ownerKey, "owner key") + try requireNoEmbeddedNul(payoutAddress, "payout address") + let handle = self.handle + let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let resolver = MnemonicResolver() + var outHandle: Handle = NULL_HANDLE + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + walletId.withUnsafeBytes { (widRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + proTxHash.withUnsafeBytes { (ptRaw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in + ownerKey.withCString { cKey in + payoutAddress.withCString { cPayout in + platform_wallet_manager_tracked_masternode_prepare_update_registrar( + handle, + widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self), + cKey, + newOperatorKeyIndex != nil, newOperatorKeyIndex ?? 0, + newVotingKeyIndex != nil, newVotingKeyIndex ?? 0, + cPayout, + resolver.handle, + &outHandle) + } + } + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outHandle + }.value + return try FinalizedCoreTransaction(handle: transactionHandle) + } + + // MARK: - Stage two: explicit-values service update + + /// Reactivate a masternode after an operator-key rotation: broadcast a + /// ProUpServTx re-asserting caller-captured service values, signed with + /// the wallet's operator key at `operatorKeyIndex` (post-rotation, the + /// operator key is by definition a wallet key). For an evonode all + /// three platform values are required; for a regular masternode all + /// must be nil. `operatorPayoutAddress` follows the same reward-driven + /// rule as the unban path. + public func masternodeUpdateServiceWithValues( + walletId: Data, + proTxHash: Data, + operatorKeyIndex: UInt32, + serviceAddress: String, + platformNodeId: Data? = nil, + platformP2PPort: UInt16? = nil, + platformHTTPPort: UInt16? = nil, + operatorPayoutAddress: String? = nil + ) async throws -> Data { + guard isConfigured, handle != NULL_HANDLE, + walletId.count == 32, proTxHash.count == 32, + platformNodeId == nil || platformNodeId?.count == 20 + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, wallet id / proTxHash not 32 bytes, or platform node id not 20 bytes") + } + try requireNoEmbeddedNul(serviceAddress, "service address") + if let operatorPayoutAddress { + try requireNoEmbeddedNul(operatorPayoutAddress, "operator payout address") + } + let handle = self.handle + return try await Task.detached(priority: .userInitiated) { () -> Data in + let resolver = MnemonicResolver() + var txidTuple = zeroTxidTuple() + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + Self.withServiceValuePointers( + walletId: walletId, proTxHash: proTxHash, platformNodeId: platformNodeId + ) { widPtr, ptPtr, nodeIdPtr in + serviceAddress.withCString { cService -> PlatformWalletFFIResult in + func call(_ cPayout: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_update_service_with_values( + handle, widPtr, ptPtr, + operatorKeyIndex, + cService, + nodeIdPtr != nil, nodeIdPtr, + platformP2PPort != nil, platformP2PPort ?? 0, + platformHTTPPort != nil, platformHTTPPort ?? 0, + cPayout, + 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 + } + + /// Prepare-only sibling of `masternodeUpdateServiceWithValues`. + public func masternodePrepareUpdateServiceWithValues( + walletId: Data, + proTxHash: Data, + operatorKeyIndex: UInt32, + serviceAddress: String, + platformNodeId: Data? = nil, + platformP2PPort: UInt16? = nil, + platformHTTPPort: UInt16? = nil, + operatorPayoutAddress: String? = nil + ) async throws -> FinalizedCoreTransaction { + guard isConfigured, handle != NULL_HANDLE, + walletId.count == 32, proTxHash.count == 32, + platformNodeId == nil || platformNodeId?.count == 20 + else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, wallet id / proTxHash not 32 bytes, or platform node id not 20 bytes") + } + try requireNoEmbeddedNul(serviceAddress, "service address") + if let operatorPayoutAddress { + try requireNoEmbeddedNul(operatorPayoutAddress, "operator payout address") + } + let handle = self.handle + let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let resolver = MnemonicResolver() + var outHandle: Handle = NULL_HANDLE + let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in + Self.withServiceValuePointers( + walletId: walletId, proTxHash: proTxHash, platformNodeId: platformNodeId + ) { widPtr, ptPtr, nodeIdPtr in + serviceAddress.withCString { cService -> PlatformWalletFFIResult in + func call(_ cPayout: UnsafePointer?) -> PlatformWalletFFIResult { + platform_wallet_manager_masternode_prepare_update_service_with_values( + handle, widPtr, ptPtr, + operatorKeyIndex, + cService, + nodeIdPtr != nil, nodeIdPtr, + platformP2PPort != nil, platformP2PPort ?? 0, + platformHTTPPort != nil, platformHTTPPort ?? 0, + cPayout, + resolver.handle, + &outHandle) + } + if let operatorPayoutAddress { + return operatorPayoutAddress.withCString { call($0) } + } + return call(nil) + } + } + } + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + throw PlatformWalletError(result: result) + } + return outHandle + }.value + return try FinalizedCoreTransaction(handle: transactionHandle) + } + + /// Nested pointer marshalling for the stage-two calls: wallet id + + /// proTxHash + optional 20-byte platform node id. + private nonisolated static func withServiceValuePointers( + walletId: Data, + proTxHash: Data, + platformNodeId: Data?, + _ body: (UnsafePointer?, UnsafePointer?, UnsafePointer?) -> T + ) -> T { + walletId.withUnsafeBytes { widRaw in + proTxHash.withUnsafeBytes { ptRaw in + let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + let ptPtr = ptRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + if let platformNodeId { + return platformNodeId.withUnsafeBytes { nodeRaw in + body(widPtr, ptPtr, nodeRaw.baseAddress?.assumingMemoryBound(to: UInt8.self)) + } + } + return body(widPtr, ptPtr, nil) + } + } + } +}