From d05c97aa32152e81a2196bb1d8c2f734556afde3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 28 Aug 2026 23:17:15 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(platform-wallet):=20ProUpRegTx=20orche?= =?UTF-8?q?stration=20=E2=80=94=20rotate=20a=20masternode's=20keys=20into?= =?UTF-8?q?=20the=20wallet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute/prepare_masternode_update_registrar builds, owner-signs, funds, input-signs and (execute) broadcasts the provider update registrar transaction that rotates a masternode's operator and/or voting key to fresh wallet keys — Core's protx update_registrar — riding the same payload-finalizer seam as the update-service path. The owner's payload signature is the 65-byte compact recoverable ECDSA over base_payload_hash (Core's CHashSigner form, hash signed directly), pinned by the real testnet vector embedded in dashcore's payload tests: the vector's base_payload_hash is asserted byte-exact and the signing helper's output is recovered back to the owner key id. Preflights, before any signing or network work: the owner secret must hash to the ProRegTx's immutable keyIDOwner (fetched txid-bound); a chosen operator key must be unused across the whole masternode list under both serializations (consensus uniqueness); the payout address is always required and network-checked — the payload replaces the payout script on-chain; and rotating the operator key of a v3 extended-net-info entry is refused, since the mandatory reactivation would replace its endpoint map. Because a ProUpRegTx that changes the operator key resets the entry's service fields and PoSe-bans it until the new operator reactivates it, this commit also adds the reactivation half: the explicit-values update-service variant (prepare/execute_masternode_update_service_with_ values) re-asserts caller-captured service and platform values instead of copying the reset entry, and provider_key_candidates lists the wallet's operator/voting keys joined against the list so pickers can default to (and enforce) network-wide-unused keys. Shared registration-payload fetching is refactored out of the unban's reward rule rather than duplicated. Co-Authored-By: Claude Fable 5 --- .../src/masternode/key_candidates.rs | 88 +++ .../rs-platform-wallet/src/masternode/mod.rs | 12 +- .../src/masternode/update_registrar.rs | 596 ++++++++++++++++++ .../src/masternode/update_service.rs | 281 ++++++++- 4 files changed, 953 insertions(+), 24 deletions(-) create mode 100644 packages/rs-platform-wallet/src/masternode/key_candidates.rs create mode 100644 packages/rs-platform-wallet/src/masternode/update_registrar.rs 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..fef9eeb4c8 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -0,0 +1,88 @@ +//! 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}; + +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(), + )); + } + } + + 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) +} diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index 6c86b52610..67d2f9bf3d 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -8,12 +8,15 @@ //! 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}; 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 +33,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/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs new file mode 100644 index 0000000000..dbe76cffb7 --- /dev/null +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -0,0 +1,596 @@ +//! 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::update_service::{display_hex, fetch_registration_payload}; +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 => entry.operator_public_key, + }; + 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, + }; + + 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}" + )) + })?; + Ok(address.script_pubkey()) +} + +/// 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(()) +} + +/// 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() + ); + } +} diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 90a66fda51..eca79d650f 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -142,6 +142,185 @@ 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, + params: MasternodeUpdateServiceParams, + values: UpdateServiceValues, + operator_secret: Zeroizing<[u8; 32]>, + signer: &S, +) -> Result { + let signed = prepare_masternode_update_service_with_values( + wallet, + spv, + params, + values, + 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, + params: MasternodeUpdateServiceParams, + values: UpdateServiceValues, + operator_secret: Zeroizing<[u8; 32]>, + signer: &S, +) -> Result { + let summaries = spv + .masternode_list_summaries() + .await + .ok_or(PlatformWalletError::MasternodeListUnavailable)?; + let entry = summaries + .iter() + .find(|entry| entry.pro_tx_hash == params.pro_tx_hash) + .ok_or_else(|| { + PlatformWalletError::InvalidParameter(format!( + "masternode {} is not in the masternode list", + display_hex(¶ms.pro_tx_hash) + )) + })?; + + verify_operator_secret(&entry.operator_public_key, &operator_secret)?; + + let operator_reward = fetch_operator_reward(wallet, ¶ms.pro_tx_hash).await?; + let script_payout = resolve_operator_payout_script( + operator_reward, + params.operator_payout_address.as_deref(), + wallet.network(), + )?; + + // The values struct is the single source of platform values here — a + // params-level P2P port would be a second one. + if params.platform_p2p_port.is_some() { + return Err(PlatformWalletError::InvalidParameter( + "pass the platform P2P port inside the service values, not the params".to_string(), + )); + } + + 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 [`operator_reward_from_registration`] 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 +333,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,10 +352,8 @@ 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" ))), @@ -419,7 +589,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 +802,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 +814,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 +828,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) = From 4a837fc174b339e676d84a9abe20ded23dfc1719 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 28 Aug 2026 23:17:49 +0200 Subject: [PATCH 2/5] feat(platform-wallet-ffi): registrar-update externs, key candidates, Swift wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive entry points mirroring the update-service families: - platform_wallet_manager_masternode_update_registrar and the tracked form (host-vaulted owner key text), each with a _prepare_ sibling that registers the signed transaction in the existing core signed-transaction storage for the review-before-broadcast step. - platform_wallet_manager_masternode_update_service_with_values (+ prepare): stage two of an operator rotation — no tracked form, since the post-rotation operator key is by definition a wallet key. - platform_wallet_manager_provider_key_candidates (+ free): the wallet's operator/voting keys by index with network-wide usage, keyed by the same account-type tags every provider-key FFI uses. The unban module's derive helper generalizes to any provider kind (owner keys included) instead of being copied, and its context resolver and secret parsers are shared. Out-params are zeroed before any other pointer check, per the crate contract, with tests. Swift: masternodeUpdateRegistrar / trackedMasternodeUpdateRegistrar (+ prepare), masternodeUpdateServiceWithValues (+ prepare), and providerKeyCandidates returning typed candidate rows. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/lib.rs | 1 + .../src/masternode_update_registrar.rs | 907 ++++++++++++++++++ .../src/masternode_update_service.rs | 43 +- ...tformWalletManagerMasternodeRotation.swift | 432 +++++++++ 4 files changed, 1361 insertions(+), 22 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift 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_update_registrar.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs new file mode 100644 index 0000000000..632636f573 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs @@ -0,0 +1,907 @@ +//! 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, MasternodeUpdateServiceParams, 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 params = MasternodeUpdateServiceParams { + pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), + platform_p2p_port: None, + operator_payout_address, + }; + 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, + params, + values, + 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 params = MasternodeUpdateServiceParams { + pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), + platform_p2p_port: None, + operator_payout_address, + }; + 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, + params, + values, + 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 + +/// 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; + + /// 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift new file mode 100644 index 0000000000..ad15d59c6c --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift @@ -0,0 +1,432 @@ +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) +} + +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 else { + throw PlatformWalletError.invalidParameter( + "Manager not configured, or wallet id not 32 bytes") + } + 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") + } + 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") + } + 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") + } + 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") + } + 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") + } + 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") + } + 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) + } + } + } +} From 58ed6b7afdbcf93b556129900e748242bcc9eda5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 29 Aug 2026 00:27:41 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix(platform-wallet):=20review=20=E2=80=94?= =?UTF-8?q?=20bound=20the=20candidates=20count,=20fix=20a=20literal's=20em?= =?UTF-8?q?bedded=20indentation,=20repoint=20a=20renamed=20doc=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - provider_key_candidates now refuses counts above a shared MAX_PROVIDER_KEY_CANDIDATES (256) before any allocation — an arbitrary external count fed Vec::with_capacity and could abort the process. The FFI re-exports the bound (asserted equal in tests) and the Swift wrapper guards against it up front. - The values-path extended-net-info error message had the source indentation baked into the literal (missing line continuations). - The registration-fetch doc pointed at a function renamed in this branch. Co-Authored-By: Claude Fable 5 --- .../src/masternode_update_registrar.rs | 13 +++++++++++++ .../src/masternode/key_candidates.rs | 11 +++++++++++ packages/rs-platform-wallet/src/masternode/mod.rs | 4 +++- .../src/masternode/update_service.rs | 6 ++++-- .../PlatformWalletManagerMasternodeRotation.swift | 6 ++++-- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs index 632636f573..12e0dbd27c 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs @@ -635,6 +635,11 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi // 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)] @@ -766,6 +771,14 @@ 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. diff --git a/packages/rs-platform-wallet/src/masternode/key_candidates.rs b/packages/rs-platform-wallet/src/masternode/key_candidates.rs index fef9eeb4c8..cb8f2d1bdf 100644 --- a/packages/rs-platform-wallet/src/masternode/key_candidates.rs +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -9,6 +9,11 @@ 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; @@ -49,6 +54,12 @@ pub fn provider_key_candidates( )); } } + 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 { diff --git a/packages/rs-platform-wallet/src/masternode/mod.rs b/packages/rs-platform-wallet/src/masternode/mod.rs index 67d2f9bf3d..5b455e9d00 100644 --- a/packages/rs-platform-wallet/src/masternode/mod.rs +++ b/packages/rs-platform-wallet/src/masternode/mod.rs @@ -16,7 +16,9 @@ pub mod tracked; pub mod update_registrar; pub mod update_service; -pub use key_candidates::{provider_key_candidates, ProviderKeyCandidate}; +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, diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index eca79d650f..53c7ad079e 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -253,7 +253,9 @@ pub(crate) fn prepare_update_service_placeholder_from_values( ) -> 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" + "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(), )); } @@ -313,7 +315,7 @@ pub(crate) fn prepare_update_service_placeholder_from_values( } /// Fetch the masternode's ProRegTx via DAPI Core and return its payload, -/// txid-bound (see [`operator_reward_from_registration`] for why the +/// 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. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift index ad15d59c6c..cde24ab7c4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift @@ -46,9 +46,11 @@ extension PlatformWalletManager { kind: RotationKeyKind, count: UInt32 = 20 ) async throws -> [ProviderKeyCandidate] { - guard isConfigured, handle != NULL_HANDLE, walletId.count == 32 else { + guard isConfigured, handle != NULL_HANDLE, walletId.count == 32, + count <= UInt32(PLATFORM_WALLET_PROVIDER_KEY_CANDIDATES_MAX) + else { throw PlatformWalletError.invalidParameter( - "Manager not configured, or wallet id not 32 bytes") + "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 From 8b66b4bf9248b8d2a29c7ad787a5e7c6e9ca590e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 29 Aug 2026 07:00:46 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(platform-wallet):=20review=20=E2=80=94?= =?UTF-8?q?=20consensus=20payout=20rules,=20deterministic=20legacy-key=20h?= =?UTF-8?q?andling,=20values-path=20semantics,=20NUL=20guards,=20candidate?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Payout scripts are now gated to P2PKH/P2SH in both payout resolvers (consensus rejects every other type as bad-protx-payee), and the registrar refuses 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, so the doomed transaction never gets signed. - A kept operator key is normalized by the ENTRY VERSION, not the bytes: MasternodeListSummary gains operator_key_is_legacy (entry.version < 2, persisted leniently), and a legacy key re-entering a version-2 payload is parsed under Legacy and reserialized to basic. Byte-sniffing was proven unsound in tests — legacy bytes also parse under the basic scheme as a different flag reading. - The explicit-values service path takes pro_tx_hash and the payout address directly instead of MasternodeUpdateServiceParams, whose documented platform_p2p_port semantics contradicted the values API. - Swift wrappers guard every string parameter against embedded NUL before C-string marshalling, matching the existing wrappers — a truncated payout/key/service must never differ from what the caller supplied. - provider_key_candidates gains behavioral tests: modern- and legacy-serialization operator joins, voting key-id joins, unused retention, kind refusals, the zero count and the bound. Co-Authored-By: Claude Fable 5 --- .../src/masternode_locator.rs | 1 + .../src/masternode_update_registrar.rs | 22 +-- .../src/masternode/key_candidates.rs | 125 ++++++++++++ .../rs-platform-wallet/src/masternode/list.rs | 9 + .../src/masternode/tracked.rs | 2 + .../src/masternode/update_registrar.rs | 178 +++++++++++++++++- .../src/masternode/update_service.rs | 46 +++-- ...tformWalletManagerMasternodeRotation.swift | 25 +++ 8 files changed, 375 insertions(+), 33 deletions(-) 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 index 12e0dbd27c..270d9c8dae 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs @@ -27,8 +27,8 @@ 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, MasternodeUpdateServiceParams, OwnerSecret, - ProviderKeyCandidate, UpdateServiceValues, + MasternodeKeyRole, MasternodeUpdateRegistrarParams, OwnerSecret, ProviderKeyCandidate, + UpdateServiceValues, }; use platform_wallet::ProviderKeyKind; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; @@ -494,11 +494,7 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service_with_ Ok(text) => text, Err(e) => return e, }; - let params = MasternodeUpdateServiceParams { - pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), - platform_p2p_port: None, - operator_payout_address, - }; + 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, @@ -525,8 +521,9 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_update_service_with_ execute_masternode_update_service_with_values( &wallet, &spv, - params, + target, values, + operator_payout_address, operator_secret, &signer, ) @@ -587,11 +584,7 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi Ok(text) => text, Err(e) => return e, }; - let params = MasternodeUpdateServiceParams { - pro_tx_hash: std::ptr::read(pro_tx_hash as *const [u8; 32]), - platform_p2p_port: None, - operator_payout_address, - }; + 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, @@ -618,8 +611,9 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_prepare_update_servi prepare_masternode_update_service_with_values( &wallet, &spv, - params, + target, values, + operator_payout_address, operator_secret, &signer, ) diff --git a/packages/rs-platform-wallet/src/masternode/key_candidates.rs b/packages/rs-platform-wallet/src/masternode/key_candidates.rs index cb8f2d1bdf..5ccfdfdce2 100644 --- a/packages/rs-platform-wallet/src/masternode/key_candidates.rs +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -97,3 +97,128 @@ pub fn provider_key_candidates( } 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/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 index dbe76cffb7..bf1bcfb301 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -32,7 +32,10 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::{ use zeroize::Zeroizing; use super::list::MasternodeListSummary; -use super::update_service::{display_hex, fetch_registration_payload}; +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; @@ -160,7 +163,10 @@ pub async fn prepare_masternode_update_registrar entry.operator_public_key, + 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) => { @@ -171,6 +177,14 @@ pub async fn prepare_masternode_update_registrar 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 @@ -239,7 +253,9 @@ pub(crate) fn resolve_owner_payout_script( "payout address is for another network: {e}" )) })?; - Ok(address.script_pubkey()) + let script = address.script_pubkey(); + require_standard_payout_script(&script)?; + Ok(script) } /// Refuse a candidate operator key already registered to any masternode — @@ -265,6 +281,69 @@ pub(crate) fn ensure_operator_key_unused( 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 @@ -594,3 +673,96 @@ mod tests { ); } } + +#[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 53c7ad079e..ec2fbe2058 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -175,16 +175,18 @@ pub struct UpdateServiceValues { pub async fn execute_masternode_update_service_with_values( wallet: &PlatformWallet, spv: &SpvRuntime, - params: MasternodeUpdateServiceParams, + 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, - params, + pro_tx_hash, values, + operator_payout_address, operator_secret, signer, ) @@ -198,8 +200,9 @@ pub async fn execute_masternode_update_service_with_values( wallet: &PlatformWallet, spv: &SpvRuntime, - params: MasternodeUpdateServiceParams, + pro_tx_hash: [u8; 32], values: UpdateServiceValues, + operator_payout_address: Option, operator_secret: Zeroizing<[u8; 32]>, signer: &S, ) -> Result { @@ -209,31 +212,23 @@ pub async fn prepare_masternode_update_service_with_values 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 @@ -401,7 +413,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) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift index cde24ab7c4..a61d2b8eb2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift @@ -32,6 +32,17 @@ private func zeroTxidTuple() -> ( 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 @@ -118,6 +129,7 @@ extension PlatformWalletManager { 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() @@ -165,6 +177,7 @@ extension PlatformWalletManager { 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() @@ -213,6 +226,8 @@ extension PlatformWalletManager { 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() @@ -260,6 +275,8 @@ extension PlatformWalletManager { 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() @@ -319,6 +336,10 @@ extension PlatformWalletManager { 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() @@ -373,6 +394,10 @@ extension PlatformWalletManager { 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() From c3f5cec8d1f2a0c2528f4bd0cf210beaa156e290 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 29 Aug 2026 07:13:05 +0200 Subject: [PATCH 5/5] style: let rustfmt manage the shared registration-payload signatures The fully-qualified ProviderRegistrationPayload return type pushed both shared-helper signatures past rustfmt's max width, so the formatter was skipping the lines entirely and the missing space before the brace survived every fmt pass. Import the type instead so the signatures are short enough for rustfmt to own. --- .../rs-platform-wallet/src/masternode/update_service.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index ec2fbe2058..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, @@ -317,7 +319,7 @@ pub(crate) fn prepare_update_service_placeholder_from_values( pub(crate) async fn fetch_registration_payload( wallet: &PlatformWallet, pro_tx_hash: &[u8; 32], -) -> Result{ +) -> Result { let display = display_hex(pro_tx_hash); let fetched = wallet .sdk() @@ -340,7 +342,7 @@ pub(crate) async fn fetch_registration_payload( pub(crate) fn registration_payload_from_fetched( pro_tx_hash: &[u8; 32], transaction: dashcore::Transaction, -) -> Result{ +) -> Result { let expected = Txid::from_byte_array(*pro_tx_hash); let actual = transaction.txid(); if actual != expected {