From d05c97aa32152e81a2196bb1d8c2f734556afde3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 28 Aug 2026 23:17:15 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat(platform-wallet):=20ProUpRegTx=20orc?= =?UTF-8?q?hestration=20=E2=80=94=20rotate=20a=20masternode's=20keys=20int?= =?UTF-8?q?o=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 00000000000..fef9eeb4c89 --- /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 6c86b526105..67d2f9bf3d0 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 00000000000..dbe76cffb75 --- /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 90a66fda515..eca79d650f2 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 02/13] 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 74aa7598e08..ff1795cc402 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 00000000000..632636f5735 --- /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 f716669be84..9ad8c24a1bb 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 00000000000..ad15d59c6c2 --- /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 03/13] =?UTF-8?q?fix(platform-wallet):=20review=20?= =?UTF-8?q?=E2=80=94=20bound=20the=20candidates=20count,=20fix=20a=20liter?= =?UTF-8?q?al's=20embedded=20indentation,=20repoint=20a=20renamed=20doc=20?= =?UTF-8?q?link?= 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 632636f5735..12e0dbd27c1 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 fef9eeb4c89..cb8f2d1bdf0 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 67d2f9bf3d0..5b455e9d001 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 eca79d650f2..53c7ad079ec 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 ad15d59c6c2..cde24ab7c46 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 04/13] =?UTF-8?q?fix(platform-wallet):=20review=20?= =?UTF-8?q?=E2=80=94=20consensus=20payout=20rules,=20deterministic=20legac?= =?UTF-8?q?y-key=20handling,=20values-path=20semantics,=20NUL=20guards,=20?= =?UTF-8?q?candidate=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 30e2dd18e93..66a1e3af789 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 12e0dbd27c1..270d9c8dae1 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 cb8f2d1bdf0..5ccfdfdce2c 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 41614c029a5..c870c495228 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 66e179015b5..9925b0b7414 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 dbe76cffb75..bf1bcfb3015 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 53c7ad079ec..ec2fbe20589 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 cde24ab7c46..a61d2b8eb25 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 05/13] 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 ec2fbe20589..e2ebdb7a99a 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 { From b536eb39c60d704e49259cd646a4b0b496e71329 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 7 Sep 2026 08:11:46 +0700 Subject: [PATCH 06/13] fix(platform-wallet): preflight collateral reuse and caller-supplied service values before funding Review follow-ups on the ProUpRegTx / ProUpServTx orchestrators: - Resolve the masternode's collateral script from the txid-bound ProRegTx (internal collateral) or a txid-bound fetch of the external collateral transaction, and refuse a final voting key or owner key at the collateral's P2PKH destination (Core's bad-protx-collateral-reuse); for a v3 entry also refuse a payout equal to the collateral script (bad-protx-payee-reuse). Candidate discovery joins against DML voting fields only, so a key that once funded the collateral looked unused. - Validate explicit ProUpServTx values against MnNetInfo::ValidateService, CheckProviderNetworkFields and the network-wide uniqueness pass: IPv4 only, non-zero routable port with the mainnet-port rule, non-null platform node id, mainnet platform port defaults, no port collisions, and no service endpoint or platform node id already advertised by any other entry. MasternodeListSummary now carries every socket endpoint of an extended entry's map so the uniqueness check sees secondary addresses, with the snapshot format extended backward-compatibly. - Keep the typed dash_sdk::Error when a transaction fetch fails; the invalid-data shapes are reserved for responses that fail validation. - Split the registrar orchestrator so the whole wiring below the network fetches is exercised by a funded test that asserts the proTxHash, operator key, voting hash, payout script, inputs hash and recovered owner signature, plus the collateral preflight wired through it. Co-Authored-By: Claude Fable 5.1 --- .../src/masternode_locator.rs | 1 + .../src/masternode/key_candidates.rs | 14 +- .../rs-platform-wallet/src/masternode/list.rs | 66 ++- .../src/masternode/tracked.rs | 23 +- .../src/masternode/update_registrar.rs | 377 +++++++++++++++- .../src/masternode/update_service.rs | 418 +++++++++++++++++- .../rs-platform-wallet/src/test_support.rs | 18 + 7 files changed, 864 insertions(+), 53 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs index 66a1e3af789..e488439ce29 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_locator.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_locator.rs @@ -314,6 +314,7 @@ mod tests { MasternodeListSummary { pro_tx_hash: [1u8; 32], service_address: Some("1.2.3.4:9999".parse().unwrap()), + service_addresses: vec!["1.2.3.4:9999".parse().unwrap()], platform_http_port: Some(443), operator_public_key: [2u8; 48], voting_key_id: [3u8; 20], diff --git a/packages/rs-platform-wallet/src/masternode/key_candidates.rs b/packages/rs-platform-wallet/src/masternode/key_candidates.rs index 5ccfdfdce2c..56c11090b9f 100644 --- a/packages/rs-platform-wallet/src/masternode/key_candidates.rs +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -103,7 +103,7 @@ 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::test_support::sync_test_platform_wallet; use crate::wallet::platform_wallet::PlatformWallet; use std::sync::Arc; @@ -111,17 +111,7 @@ mod tests { /// 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 + sync_test_platform_wallet() } #[test] diff --git a/packages/rs-platform-wallet/src/masternode/list.rs b/packages/rs-platform-wallet/src/masternode/list.rs index c870c495228..db7a49ba57c 100644 --- a/packages/rs-platform-wallet/src/masternode/list.rs +++ b/packages/rs-platform-wallet/src/masternode/list.rs @@ -29,6 +29,13 @@ pub struct MasternodeListSummary { /// Primary routable Core P2P endpoint. `None` for Tor / I2P / CJDNS / /// domain-only entries, which have no `SocketAddr` form. pub service_address: Option, + /// EVERY advertised endpoint with a socket form — the primary plus, for + /// a v3 extended entry, the rest of its endpoint map across all + /// purposes. Core registers each of them as a unique property of the + /// masternode (`bad-protx-dup-netinfo-entry`), so uniqueness preflights + /// must compare against all of these, never just `service_address`. + /// Defaults to the primary alone on pre-field snapshots. + pub service_addresses: Vec, /// Platform HTTP (DAPI gRPC) port — evonodes only. pub platform_http_port: Option, /// Operator BLS public key (48 bytes, as serialized in the list — the @@ -86,6 +93,7 @@ impl MasternodeListSummary { Self { pro_tx_hash, service_address: entry.service_address.primary_service_address(), + service_addresses: all_socket_addresses(&entry.service_address), platform_http_port, operator_public_key, voting_key_id, @@ -118,6 +126,52 @@ impl MasternodeListSummary { } } +/// Every endpoint of `net_info` with a socket form, across all purposes — +/// the shape Core's unique-property index holds them in (each entry of an +/// extended map is registered individually). Tor / I2P / CJDNS / domain +/// entries have no `SocketAddr` form and are skipped; a caller-supplied +/// `ip:port` value can never collide with them anyway. +fn all_socket_addresses( + net_info: &dashcore::sml::masternode_list_entry::MasternodeNetInfo, +) -> Vec { + use dashcore::sml::masternode_list_entry::net_info::{Bip155Network, NetInfoEntry}; + use dashcore::sml::masternode_list_entry::MasternodeNetInfo; + use std::net::{SocketAddrV4, SocketAddrV6}; + + match net_info { + MasternodeNetInfo::Legacy(addr) => vec![*addr], + MasternodeNetInfo::Extended(info) => info + .purposes + .iter() + .flat_map(|(_, entries)| entries.iter()) + .filter_map(|entry| match entry { + NetInfoEntry::Service { + network: Bip155Network::Ipv4, + addr, + port, + } => { + let octets: [u8; 4] = addr.as_slice().try_into().ok()?; + Some(SocketAddr::V4(SocketAddrV4::new(octets.into(), *port))) + } + NetInfoEntry::Service { + network: Bip155Network::Ipv6, + addr, + port, + } => { + let octets: [u8; 16] = addr.as_slice().try_into().ok()?; + Some(SocketAddr::V6(SocketAddrV6::new( + octets.into(), + *port, + 0, + 0, + ))) + } + _ => None, + }) + .collect(), + } +} + /// One way of asking the list for a masternode. Every variant is matched /// against the list's own fields — nothing here needs the wallet or the /// network. @@ -173,12 +227,11 @@ pub(crate) mod test_support { /// operator key and voting key id are all derived from `seed` so every /// entry is distinct and recognizable. pub(crate) fn masternode(seed: u8) -> MasternodeListSummary { + let primary = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, seed), 9999)); MasternodeListSummary { pro_tx_hash: [seed; 32], - service_address: Some(SocketAddr::V4(SocketAddrV4::new( - Ipv4Addr::new(10, 0, 0, seed), - 9999, - ))), + service_address: Some(primary), + service_addresses: vec![primary], platform_http_port: None, operator_public_key: [seed; 48], voting_key_id: [seed; 20], @@ -251,6 +304,11 @@ mod tests { s.service_address, Some("1.2.3.4:19999".parse::().unwrap()) ); + assert_eq!( + s.service_addresses, + vec!["1.2.3.4:19999".parse::().unwrap()], + "a legacy entry's endpoint list is its single address" + ); assert_eq!(s.platform_http_port, Some(1443)); assert_eq!(s.operator_public_key, [9u8; 48]); assert_eq!(s.voting_key_id, [5u8; 20]); diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs index 9925b0b7414..5cded93f810 100644 --- a/packages/rs-platform-wallet/src/masternode/tracked.rs +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -299,6 +299,11 @@ fn list_to_json(list: &MasternodeListSummary) -> Value { json!({ "proTxHash": hex::encode(list.pro_tx_hash), "serviceAddress": list.service_address.map(|a| a.to_string()), + "serviceAddresses": list + .service_addresses + .iter() + .map(|a| a.to_string()) + .collect::>(), "platformHttpPort": list.platform_http_port, "operatorPubKey": hex::encode(list.operator_public_key), "votingKeyId": hex::encode(list.voting_key_id), @@ -311,11 +316,23 @@ fn list_to_json(list: &MasternodeListSummary) -> Value { } fn list_from_json(value: &Value) -> Option { + let service_address: Option = value["serviceAddress"] + .as_str() + .and_then(|s| s.parse().ok()); Some(MasternodeListSummary { pro_tx_hash: parse_hex(&value["proTxHash"])?, - service_address: value["serviceAddress"] - .as_str() - .and_then(|s| s.parse().ok()), + service_address, + // Absent on pre-field snapshots: the primary alone is the best + // reconstruction; the next refresh rewrites it from the live entry. + service_addresses: value["serviceAddresses"] + .as_array() + .map(|entries| { + entries + .iter() + .filter_map(|e| e.as_str()?.parse().ok()) + .collect() + }) + .unwrap_or_else(|| service_address.into_iter().collect()), platform_http_port: value["platformHttpPort"].as_u64().map(|p| p as u16), operator_public_key: parse_hex(&value["operatorPubKey"])?, voting_key_id: parse_hex(&value["votingKeyId"])?, diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index bf1bcfb3015..ead261efde1 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -33,8 +33,11 @@ use zeroize::Zeroizing; use super::list::MasternodeListSummary; use super::locator::p2pkh_script_hash; +use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderRegistrationPayload; + use super::update_service::{ - display_hex, fetch_registration_payload, require_standard_payout_script, + display_hex, fetch_registration_transaction, fetch_transaction_checked, + registration_payload_from_fetched, require_standard_payout_script, }; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -95,6 +98,47 @@ pub async fn prepare_masternode_update_registrar Result { + let summaries = spv + .masternode_list_summaries() + .await + .ok_or(PlatformWalletError::MasternodeListUnavailable)?; + + // The owner key is immutable — set at registration, never rotatable — + // so the ProRegTx's keyIDOwner is the authority the supplied secret is + // verified against (the masternode list does not carry it). The full + // transaction is fetched because an internal collateral is one of its + // own outputs. + let registration_tx = fetch_registration_transaction(wallet, ¶ms.pro_tx_hash).await?; + let registration = + registration_payload_from_fetched(¶ms.pro_tx_hash, registration_tx.clone())?; + let collateral_script = + resolve_collateral_script(wallet, ®istration_tx, ®istration).await?; + + let placeholder = assemble_update_registrar_placeholder( + wallet, + &summaries, + ®istration, + &collateral_script, + ¶ms, + &owner, + )?; + + build_sign_update_registrar(wallet.core(), placeholder, owner, signer).await +} + +/// Everything between the network reads and the funding build: resolve the +/// live entry, verify the owner secret, resolve every payload field, run +/// the consensus preflights, and assemble the placeholder payload. Split +/// out so the whole wiring is testable without SPV or DAPI — the public +/// orchestrator adds only the three fetches around it. +pub(crate) fn assemble_update_registrar_placeholder( + wallet: &PlatformWallet, + summaries: &[MasternodeListSummary], + registration: &ProviderRegistrationPayload, + collateral_script: &ScriptBuf, + params: &MasternodeUpdateRegistrarParams, + owner: &OwnerSecret, +) -> 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" @@ -102,10 +146,6 @@ pub async fn prepare_masternode_update_registrar normalize_operator_key_to_basic( @@ -184,8 +220,15 @@ pub async fn prepare_masternode_update_registrar Result { + let outpoint = registration.collateral_outpoint; + if outpoint.txid == Txid::all_zeros() { + collateral_output_script(registration_tx, outpoint.vout) + } else { + let external = fetch_transaction_checked( + wallet, + &outpoint.txid.to_byte_array(), + "collateral transaction", + ) + .await?; + collateral_output_script(&external, outpoint.vout) + } +} + +/// Output `vout`'s scriptPubKey, refusing an out-of-range index. +pub(crate) fn collateral_output_script( + transaction: &dashcore::Transaction, + vout: u32, +) -> Result { + transaction + .output + .get(vout as usize) + .map(|output| output.script_pubkey.clone()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "the collateral outpoint index {vout} is out of range for its transaction" + )) + }) +} + +/// Core resolves the masternode's collateral UTXO and rejects a ProUpRegTx +/// whose final voting key (or the immutable owner key) is the collateral's +/// P2PKH destination (`bad-protx-collateral-reuse`) — the rule keeping the +/// collateral key off an online voting server. Candidate discovery joins +/// wallet keys against DML voting fields only, so a key whose address once +/// funded this node's collateral looks unused there; this is the check that +/// stops it before funding. From v3 (ExtAddr) entries Core also rejects a +/// payout script equal to the collateral script (`bad-protx-payee-reuse`); +/// this payload is version 2, so that arm applies exactly when the ENTRY is +/// v3 — Core gates on `max(entry version, payload version)`, and an entry +/// advertises extended net info exactly when it is v3+. +pub(crate) fn ensure_collateral_not_reused( + collateral_script: &ScriptBuf, + owner_key_hash: &PubkeyHash, + final_voting_key_hash: &[u8; 20], + script_payout: &ScriptBuf, + entry_is_v3: bool, +) -> Result<(), PlatformWalletError> { + if let Some(collateral_key) = p2pkh_script_hash(collateral_script.as_bytes()) { + if collateral_key == owner_key_hash.to_byte_array() + || collateral_key == *final_voting_key_hash + { + return Err(PlatformWalletError::InvalidParameter( + "the chosen voting key (or the owner key) is the masternode's collateral \ + address — consensus rejects reusing the collateral key \ + (`bad-protx-collateral-reuse`); pick a different voting key" + .to_string(), + )); + } + } + if entry_is_v3 && script_payout == collateral_script { + return Err(PlatformWalletError::InvalidParameter( + "the payout address is the masternode's collateral address — consensus rejects \ + paying the payout to the collateral (`bad-protx-payee-reuse`); pick a different \ + payout address" + .to_string(), + )); + } + Ok(()) } /// Refuse an owner secret whose public key hash does not match the @@ -598,6 +721,170 @@ mod tests { .expect("an unused key passes"); } + /// The full prepare wiring below the network fetches — entry lookup, + /// owner verification, fresh-key derivation, uniqueness and reuse + /// preflights, payload assembly — driven through the same seam the + /// public orchestrator uses, then funded, signed and broadcast. The + /// public function adds only the SPV summaries read, the txid-bound + /// ProRegTx fetch and the collateral resolution around this. + #[test] + fn assembles_and_signs_through_the_prepare_wiring() { + use dashcore::blockdata::transaction::special_transaction::provider_registration::{ + ProviderMasternodeType, ProviderRegistrationPayload, + }; + use dashcore::OutPoint; + + let wallet = crate::test_support::sync_test_platform_wallet(); + + let derived_operator: [u8; 48] = wallet + .derive_provider_key_at_index(ProviderKeyKind::Operator, 0, None, false) + .expect("operator key 0") + .public_key_bytes + .as_slice() + .try_into() + .expect("48 bytes"); + let derived_voting = wallet + .derive_provider_key_at_index(ProviderKeyKind::Voting, 0, None, false) + .expect("voting key 0"); + let expected_voting_hash = + hash160::Hash::hash(&derived_voting.public_key_bytes).to_byte_array(); + + let summaries = vec![masternode(0x11), masternode(0x22)]; + // The mock-SDK fixture reports mainnet, so the payout address must + // be a mainnet one — `wallet.network()` gates it. + let payout_address = DashAddress::dummy(Network::Mainnet, 3); + let params = MasternodeUpdateRegistrarParams { + pro_tx_hash: [0x11; 32], + new_operator_key_index: Some(0), + new_voting_key_index: Some(0), + payout_address: payout_address.to_string(), + }; + let registration = ProviderRegistrationPayload { + version: ProviderRegistrationPayload::CURRENT_VERSION, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }, + service_address: "10.0.0.17:9999".parse().expect("socket address"), + owner_key_hash: owner_key_hash(), + operator_public_key: BLSPublicKey::from([0x11; 48]), + voting_key_hash: PubkeyHash::from_byte_array([0x11; 20]), + operator_reward: 0, + script_payout: ScriptBuf::new(), + inputs_hash: InputsHash::all_zeros(), + signature: vec![], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + }; + let collateral = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0xAB; 20])); + + let placeholder = assemble_update_registrar_placeholder( + &wallet, + &summaries, + ®istration, + &collateral, + ¶ms, + &owner(), + ) + .expect("the wiring assembles the placeholder"); + + assert_eq!(placeholder.pro_tx_hash, Txid::from_byte_array([0x11; 32])); + assert_eq!(placeholder.provider_mode, 0); + assert_eq!( + placeholder.operator_public_key, + BLSPublicKey::from(derived_operator), + "the chosen wallet operator key lands in the payload" + ); + assert_eq!( + placeholder.voting_key_hash, + PubkeyHash::from_byte_array(expected_voting_hash), + "the chosen wallet voting key's hash160 lands in the payload" + ); + assert_eq!(placeholder.script_payout, payout_address.script_pubkey()); + assert_eq!(placeholder.inputs_hash, InputsHash::all_zeros()); + assert!(placeholder.payload_sig.is_empty()); + + // The collateral preflight is wired through: a collateral paid to + // the chosen voting key's address refuses the whole assembly. + let voting_collateral = + ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(expected_voting_hash)); + let err = assemble_update_registrar_placeholder( + &wallet, + &summaries, + ®istration, + &voting_collateral, + ¶ms, + &owner(), + ) + .expect_err("collateral reuse by the chosen voting key is refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // Fund, sign and broadcast the assembled placeholder end-to-end. + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime"); + runtime.block_on(async move { + 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 prepared = build_sign_update_registrar(&core, placeholder, owner(), &signer) + .await + .expect("the assembled placeholder funds and signs"); + let txid = core + .broadcast_finalized_transaction(&prepared) + .await + .expect("broadcasts"); + + let sent = broadcaster.sent.lock().expect("broadcaster lock"); + assert_eq!(sent.len(), 1); + let tx = &sent[0]; + assert_eq!(tx.txid(), txid); + let Some(TransactionPayload::ProviderUpdateRegistrarPayloadType(payload)) = + &tx.special_transaction_payload + else { + panic!("the broadcast transaction must carry the ProUpRegTx payload"); + }; + assert_eq!(payload.pro_tx_hash, Txid::from_byte_array([0x11; 32])); + assert_eq!( + payload.operator_public_key, + BLSPublicKey::from(derived_operator) + ); + assert_eq!( + payload.voting_key_hash, + PubkeyHash::from_byte_array(expected_voting_hash) + ); + assert_eq!(payload.inputs_hash, tx.hash_inputs()); + + // The owner signature recovers to the owner key id over the + // finished payload hash — Core's CheckHashSig check. + 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() + ); + }); + } + #[tokio::test] async fn builds_signs_and_broadcasts_a_pro_up_reg_tx() { let (wallet_manager, wallet_id, generation, signer) = @@ -740,6 +1027,68 @@ mod review_tests { .expect("a P2SH payout cannot reuse a key id"); } + /// Consensus loads the collateral UTXO and rejects reusing its P2PKH + /// destination as the owner or final voting key + /// (`bad-protx-collateral-reuse`) — the DML never shows a collateral + /// address, so candidate discovery alone cannot catch this. For a v3 + /// entry it also rejects a payout equal to the collateral script + /// (`bad-protx-payee-reuse`). + #[test] + fn collateral_reuse_is_refused() { + let owner = PubkeyHash::from_byte_array([0x11; 20]); + let voting = [0x22u8; 20]; + let payout = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0x33; 20])); + + let voting_collateral = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(voting)); + let err = ensure_collateral_not_reused(&voting_collateral, &owner, &voting, &payout, false) + .expect_err("a collateral at the final voting key's address is refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let owner_collateral = ScriptBuf::new_p2pkh(&owner); + let err = ensure_collateral_not_reused(&owner_collateral, &owner, &voting, &payout, false) + .expect_err("a collateral at the owner key's address is refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + let unrelated = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0x44; 20])); + ensure_collateral_not_reused(&unrelated, &owner, &voting, &payout, true) + .expect("an unrelated collateral passes both gates"); + + // Payout == collateral: Core applies this arm only from v3 + // (ExtAddr) entries — a P2SH collateral carries no key id, so only + // the gated script-equality check can fire. + let p2sh = ScriptBuf::new_p2sh(&dashcore::ScriptHash::from_byte_array([0x55; 20])); + ensure_collateral_not_reused(&p2sh, &owner, &voting, &p2sh, false) + .expect("payout-collateral reuse is not checked below v3"); + let err = ensure_collateral_not_reused(&p2sh, &owner, &voting, &p2sh, true) + .expect_err("a v3 entry's payout must not be the collateral script"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + } + + /// The collateral script comes from an output index inside a fetched + /// transaction — bounds-checked, never a panic on hostile data. + #[test] + fn collateral_output_script_is_bounds_checked() { + let script = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0x66; 20])); + let transaction = dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![dashcore::TxOut { + value: 1_000_000_000_000, + script_pubkey: script.clone(), + }], + special_transaction_payload: None, + }; + + assert_eq!( + collateral_output_script(&transaction, 0).expect("in-range output"), + script + ); + let err = collateral_output_script(&transaction, 1) + .expect_err("an out-of-range outpoint index is refused"); + assert!(matches!(err, PlatformWalletError::InvalidIdentityData(_))); + } + /// 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 diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index e2ebdb7a99a..9b98ec69dc4 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -32,7 +32,7 @@ use dashcore::{Address as DashAddress, Network, Txid}; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, TransactionSigner, }; -use std::net::{IpAddr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use zeroize::Zeroizing; use super::list::MasternodeListSummary; @@ -223,6 +223,7 @@ pub async fn prepare_masternode_update_service_with_values bool { + let octets = ip.octets(); + let shared = octets[0] == 100 && (octets[1] & 0b1100_0000) == 64; // RFC 6598 + let benchmarking = octets[0] == 198 && (octets[1] & 0xFE) == 18; // RFC 2544 + !(ip.is_unspecified() + || ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || octets[0] == 0 + || shared + || benchmarking) +} + +/// Refuse caller-supplied service values a version-2 ProUpServTx cannot +/// carry, before any funding or signing — mirroring the checks Core runs in +/// `MnNetInfo::ValidateService`, `CheckProviderNetworkFields` and +/// `CheckProUpServTx`'s uniqueness pass. The entry-copy (unban) path needs +/// none of this: it re-asserts the entry's own live values, which the +/// network already accepted. +/// +/// Presence gating of the platform triplet (all three for an evonode, none +/// for a regular masternode) stays with the placeholder builder; this +/// validates the values that were given. +pub(crate) fn validate_update_service_values( + network: Network, + entry: &MasternodeListSummary, + values: &UpdateServiceValues, + summaries: &[MasternodeListSummary], +) -> Result<(), PlatformWalletError> { + let service: SocketAddr = values.service_address.parse().map_err(|e| { + PlatformWalletError::InvalidParameter(format!( + "service address is not a valid ip:port: {e}" + )) + })?; + + // MnNetInfo::ValidateService: IPv4 only, a real port, routable off + // regtest, and the port rule — the mainnet default Core P2P port on + // mainnet, never it anywhere else. + let IpAddr::V4(ip) = service.ip() else { + return Err(PlatformWalletError::InvalidParameter( + "the service address must be IPv4 — consensus accepts only IPv4 services in a \ + version-2 payload (`bad-protx-netinfo-addr-type`)" + .to_string(), + )); + }; + if service.port() == 0 { + return Err(PlatformWalletError::InvalidParameter( + "the service port must not be 0".to_string(), + )); + } + if network != Network::Regtest && !ipv4_is_routable(ip) { + return Err(PlatformWalletError::InvalidParameter(format!( + "service address {ip} is not routable — consensus rejects reserved and private \ + ranges (`bad-protx-netinfo-addr-unroutable`)" + ))); + } + let on_mainnet = network == Network::Mainnet; + if on_mainnet != (service.port() == MAINNET_CORE_P2P_PORT) { + return Err(PlatformWalletError::InvalidParameter(format!( + "service port {}: consensus requires port {MAINNET_CORE_P2P_PORT} on mainnet and \ + forbids it on every other network (`bad-protx-netinfo-port`)", + service.port() + ))); + } + + // CheckProviderNetworkFields, for whichever platform values were given. + if let Some(node_id) = values.platform_node_id { + if node_id == [0u8; 20] { + return Err(PlatformWalletError::InvalidParameter( + "the platform node id must not be all zeroes (`bad-protx-platform-nodeid`)" + .to_string(), + )); + } + } + for (port, name, mainnet_default) in [ + ( + values.platform_p2p_port, + "platform P2P", + MAINNET_PLATFORM_P2P_PORT, + ), + ( + values.platform_http_port, + "platform HTTP", + MAINNET_PLATFORM_HTTP_PORT, + ), + ] { + let Some(port) = port else { continue }; + if on_mainnet && port != mainnet_default { + return Err(PlatformWalletError::InvalidParameter(format!( + "the {name} port must be {mainnet_default} on mainnet, got {port}" + ))); + } + if port == MAINNET_CORE_P2P_PORT { + return Err(PlatformWalletError::InvalidParameter(format!( + "the {name} port must not be the mainnet Core P2P port \ + ({MAINNET_CORE_P2P_PORT})" + ))); + } + if port == service.port() { + return Err(PlatformWalletError::InvalidParameter(format!( + "the {name} port must differ from the Core P2P service port \ + (`bad-protx-platform-dup-ports`)" + ))); + } + } + if let (Some(p2p), Some(http)) = (values.platform_p2p_port, values.platform_http_port) { + if p2p == http { + return Err(PlatformWalletError::InvalidParameter( + "the platform P2P and HTTP ports must differ \ + (`bad-protx-platform-dup-ports`)" + .to_string(), + )); + } + } + + // CheckProUpServTx's uniqueness pass: the service endpoint and platform + // node id are unique properties across the whole list — checked against + // EVERY endpoint other entries advertise (an extended entry registers + // each address of its endpoint map), excluding the target itself. + for other in summaries + .iter() + .filter(|other| other.pro_tx_hash != entry.pro_tx_hash) + { + if other.service_addresses.contains(&service) { + return Err(PlatformWalletError::InvalidParameter(format!( + "service address {service} is already advertised by masternode {} \ + (`bad-protx-dup-netinfo-entry`)", + display_hex(&other.pro_tx_hash) + ))); + } + if let Some(node_id) = values.platform_node_id { + if other.platform_node_id == Some(node_id) { + return Err(PlatformWalletError::InvalidParameter(format!( + "the platform node id is already used by masternode {} \ + (`bad-protx-dup-platformnodeid`)", + display_hex(&other.pro_tx_hash) + ))); + } + } + } + Ok(()) +} + +/// Fetch a transaction via DAPI Core and bind it to the txid the caller +/// asked for — the reply is unauthenticated, so nothing from it is trusted +/// until the decoded transaction hashes back to the request. A request +/// failure keeps its typed [`PlatformWalletError::Sdk`] shape so callers +/// can classify transport and retryable errors; the invalid-data shapes are +/// reserved for successful responses whose contents fail validation. +pub(crate) async fn fetch_transaction_checked( wallet: &PlatformWallet, - pro_tx_hash: &[u8; 32], -) -> Result { - let display = display_hex(pro_tx_hash); + txid_wire: &[u8; 32], + what: &str, +) -> Result { + let display = display_hex(txid_wire); let fetched = wallet .sdk() .get_transaction(&display) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "failed to fetch the registration transaction: {e}" - )) - })? + .await? .ok_or_else(|| { - PlatformWalletError::InvalidParameter(format!( - "registration transaction {display} was not found" - )) + PlatformWalletError::InvalidParameter(format!("{what} {display} was not found")) })?; - registration_payload_from_fetched(pro_tx_hash, fetched.transaction) + let transaction = fetched.transaction; + let expected = Txid::from_byte_array(*txid_wire); + let actual = transaction.txid(); + if actual != expected { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "DAPI returned transaction {actual} for requested {what} {expected}" + ))); + } + Ok(transaction) +} + +/// The masternode's full ProRegTx via DAPI Core, txid-bound. The registrar +/// update needs the whole transaction: an internal collateral is one of its +/// own outputs. +pub(crate) async fn fetch_registration_transaction( + wallet: &PlatformWallet, + pro_tx_hash: &[u8; 32], +) -> Result { + fetch_transaction_checked(wallet, pro_tx_hash, "registration transaction").await +} + +/// [`fetch_registration_transaction`] reduced to its payload. 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 transaction = fetch_registration_transaction(wallet, pro_tx_hash).await?; + registration_payload_from_fetched(pro_tx_hash, transaction) } /// Txid-bind and unwrap a fetched registration transaction's payload. @@ -913,6 +1093,204 @@ mod tests { assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + fn plain_values(service: &str) -> UpdateServiceValues { + UpdateServiceValues { + service_address: service.to_string(), + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + } + } + + /// Core's `MnNetInfo::ValidateService` rules on a caller-supplied + /// service: IPv4 only, a real port, routable off regtest, and the + /// mainnet-port rule in both directions. + #[test] + fn values_validation_enforces_core_service_rules() { + let entry = operator_entry(0x66, false); + let summaries = vec![entry.clone(), masternode(0x22)]; + let check = |network, service: &str| { + validate_update_service_values(network, &entry, &plain_values(service), &summaries) + }; + + check(Network::Testnet, "34.214.48.68:19999").expect("a routable IPv4 service passes"); + + for (label, service) in [ + ("IPv6", "[2001:db8::1]:19999"), + ("port zero", "34.214.48.68:0"), + ("private (RFC 1918)", "10.0.0.5:19999"), + ("loopback", "127.0.0.1:19999"), + ("documentation (RFC 5737)", "203.0.113.5:19999"), + ("shared (RFC 6598)", "100.64.0.1:19999"), + ("benchmarking (RFC 2544)", "198.18.0.1:19999"), + ("mainnet port off mainnet", "34.214.48.68:9999"), + ] { + assert!( + check(Network::Testnet, service).is_err(), + "{label} must be refused" + ); + } + + check(Network::Regtest, "10.0.0.5:19999").expect("regtest does not require routability"); + + check(Network::Mainnet, "34.214.48.68:9999") + .expect("mainnet requires the default Core P2P port"); + check(Network::Mainnet, "34.214.48.68:19999") + .expect_err("a non-default Core P2P port is refused on mainnet"); + } + + /// Core's `CheckProviderNetworkFields` rules on the platform triplet: + /// non-null node id, mainnet-default platform ports on mainnet, and no + /// port collisions with each other, the Core service port, or the + /// mainnet Core P2P port. + #[test] + fn values_validation_enforces_platform_field_rules() { + let entry = operator_entry(0x66, true); + let summaries = vec![entry.clone()]; + let values = |service: &str, node: [u8; 20], p2p: u16, http: u16| UpdateServiceValues { + service_address: service.to_string(), + platform_node_id: Some(node), + platform_p2p_port: Some(p2p), + platform_http_port: Some(http), + }; + let check = |network, v: &UpdateServiceValues| { + validate_update_service_values(network, &entry, v, &summaries) + }; + + check( + Network::Testnet, + &values("34.214.48.68:19999", [0x77; 20], 22000, 22001), + ) + .expect("a valid testnet platform triplet passes"); + + for (label, v) in [ + ( + "an all-zero platform node id", + values("34.214.48.68:19999", [0u8; 20], 22000, 22001), + ), + ( + "equal platform ports", + values("34.214.48.68:19999", [0x77; 20], 22000, 22000), + ), + ( + "the mainnet Core P2P port as platform P2P port", + values("34.214.48.68:19999", [0x77; 20], 9999, 22001), + ), + ( + "the mainnet Core P2P port as platform HTTP port", + values("34.214.48.68:19999", [0x77; 20], 22000, 9999), + ), + ( + "the Core service port as platform P2P port", + values("34.214.48.68:19999", [0x77; 20], 19999, 22001), + ), + ] { + assert!( + check(Network::Testnet, &v).is_err(), + "{label} must be refused" + ); + } + + check( + Network::Mainnet, + &values("34.214.48.68:9999", [0x77; 20], 26656, 443), + ) + .expect("the mainnet platform defaults pass"); + assert!( + check( + Network::Mainnet, + &values("34.214.48.68:9999", [0x77; 20], 22000, 443), + ) + .is_err(), + "a non-default platform P2P port is refused on mainnet" + ); + assert!( + check( + Network::Mainnet, + &values("34.214.48.68:9999", [0x77; 20], 26656, 8080), + ) + .is_err(), + "a non-default platform HTTP port is refused on mainnet" + ); + } + + /// Core's uniqueness pass: the service endpoint and platform node id + /// must not be advertised by ANY other masternode — including a + /// secondary endpoint of an extended entry's map, which is exactly what + /// `service_addresses` carries beyond the primary. The target's own + /// values are excluded. + #[test] + fn values_validation_enforces_network_wide_uniqueness() { + let mut entry = operator_entry(0x66, true); + let own: SocketAddr = "34.214.48.70:19999".parse().expect("socket address"); + entry.service_address = Some(own); + entry.service_addresses = vec![own]; + + let mut other = evonode(0x22); + let other_primary: SocketAddr = "34.214.48.71:19999".parse().expect("socket address"); + let other_secondary: SocketAddr = "34.214.48.72:19999".parse().expect("socket address"); + other.service_address = Some(other_primary); + other.service_addresses = vec![other_primary, other_secondary]; + let other_node_id = other.platform_node_id.expect("evonode node id"); + let summaries = vec![entry.clone(), other.clone()]; + + let with_node = |service: &str, node: [u8; 20]| UpdateServiceValues { + service_address: service.to_string(), + platform_node_id: Some(node), + platform_p2p_port: Some(22000), + platform_http_port: Some(22001), + }; + + validate_update_service_values( + Network::Testnet, + &entry, + &with_node("34.214.48.70:19999", [0x99; 20]), + &summaries, + ) + .expect("re-asserting the target's own address passes — self is excluded"); + + assert!( + validate_update_service_values( + Network::Testnet, + &entry, + &with_node("34.214.48.71:19999", [0x99; 20]), + &summaries, + ) + .is_err(), + "another entry's primary endpoint is refused" + ); + assert!( + validate_update_service_values( + Network::Testnet, + &entry, + &with_node("34.214.48.72:19999", [0x99; 20]), + &summaries, + ) + .is_err(), + "another entry's SECONDARY endpoint is refused — the endpoint map counts" + ); + assert!( + validate_update_service_values( + Network::Testnet, + &entry, + &with_node("34.214.48.73:19999", other_node_id), + &summaries, + ) + .is_err(), + "another entry's platform node id is refused" + ); + validate_update_service_values( + Network::Testnet, + &entry, + &with_node( + "34.214.48.73:19999", + entry.platform_node_id.expect("own node id"), + ), + &summaries, + ) + .expect("re-asserting the target's own node id passes"); + } + #[tokio::test] async fn builds_signs_and_broadcasts_a_pro_up_serv_tx() { let (wallet_manager, wallet_id, generation, signer) = diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 31c7abdf446..fb652c1a4c3 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -608,6 +608,24 @@ pub async fn test_platform_wallet_manager() -> ( (manager, wallet_id) } +/// Synchronous wrapper over [`test_platform_wallet_manager`] for blocking +/// tests: builds the manager on its own runtime, resolves the wallet handle +/// outside it, and leaks the manager so the registered `Arc` +/// stays alive — the same threading shape the FFI worker gives these calls. +#[cfg(test)] +pub(crate) fn sync_test_platform_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"); + std::mem::forget(manager); + wallet +} + /// Canonical all-`abandon` BIP-39 test vector. Fixed (not /// `TestWalletContext::new_random`) so every key it derives is a stable golden — /// which is what lets the signed-message tests pin an RFC6979-deterministic From 9d7d716093aa9d6418902dde36f6deb19154ec38 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 10 Sep 2026 17:33:30 +0700 Subject: [PATCH 07/13] fix(platform-wallet): keep registrar key derivation off async workers, catch P2PK collateral reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round three on the registrar path: - prepare_masternode_update_registrar ran the assembly (which derives wallet keys via tokio blocking read-locks) directly in its async body, and the candidates FFI ran provider_key_candidates inside block_on_worker's spawned task — both a documented blocking_read panic on an async worker, aborting across the C ABI. The orchestrator now assembles on the blocking pool (spawn_blocking, with a regression test driving the seam from a runtime worker), and the candidates extern awaits only the list read, deriving on the plain FFI thread like the per-index derive extern. - The collateral-reuse preflight only extracted P2PKH destinations; Core's ExtractDestination also converts a valid P2PK collateral to a PKHash over the key's original serialization, so a P2PK collateral at the voting or owner key slipped through to a deterministic consensus rejection. Both forms are extracted now, tested compressed and uncompressed. - The owner-key and voting-key collateral collisions report separate errors: re-choosing the voting key clears one, while the immutable owner key's collision cannot be fixed with a ProUpRegTx at all. Co-Authored-By: Claude Fable 5 --- .../src/masternode_update_registrar.rs | 14 +- .../src/masternode/update_registrar.rs | 286 +++++++++++++++--- 2 files changed, 246 insertions(+), 54 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 270d9c8dae1..3dc9a6ae18a 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_update_registrar.rs @@ -719,13 +719,17 @@ pub unsafe extern "C" fn platform_wallet_manager_provider_key_candidates( }; let ResolvedContext { wallet, spv, .. } = context; - let candidates = unwrap_result_or_return!(block_on_worker(async move { - let summaries = spv - .masternode_list_summaries() + // Only the list read runs on the runtime. Candidate derivation takes + // the wallet-manager lock via tokio's `blocking_read`, which panics on + // an async worker thread, so it stays on this plain FFI thread — the + // same split `platform_wallet_provider_key_at_index` uses. + let summaries = unwrap_result_or_return!(block_on_worker(async move { + spv.masternode_list_summaries() .await - .ok_or(platform_wallet::PlatformWalletError::MasternodeListUnavailable)?; - provider_key_candidates(&wallet, &summaries, kind, count) + .ok_or(platform_wallet::PlatformWalletError::MasternodeListUnavailable) })); + let candidates = + unwrap_result_or_return!(provider_key_candidates(&wallet, &summaries, kind, count)); let mut entries: Vec = candidates.into_iter().map(candidate_to_ffi).collect(); diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index ead261efde1..b01c0a2f702 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -114,18 +114,53 @@ pub async fn prepare_masternode_update_registrar, + registration: ProviderRegistrationPayload, + collateral_script: ScriptBuf, + params: MasternodeUpdateRegistrarParams, + owner: OwnerSecret, +) -> Result<(ProviderUpdateRegistrarPayload, OwnerSecret), PlatformWalletError> { + tokio::task::spawn_blocking(move || { + let placeholder = assemble_update_registrar_placeholder( + &wallet, + &summaries, + ®istration, + &collateral_script, + ¶ms, + &owner, + )?; + Ok((placeholder, owner)) + }) + .await + .map_err(|join_error| { + PlatformWalletError::KeyDerivation(format!( + "the registrar assembly task did not complete: {join_error}" + )) + })? +} + /// Everything between the network reads and the funding build: resolve the /// live entry, verify the owner secret, resolve every payload field, run /// the consensus preflights, and assemble the placeholder payload. Split @@ -282,15 +317,19 @@ pub(crate) fn collateral_output_script( /// Core resolves the masternode's collateral UTXO and rejects a ProUpRegTx /// whose final voting key (or the immutable owner key) is the collateral's -/// P2PKH destination (`bad-protx-collateral-reuse`) — the rule keeping the -/// collateral key off an online voting server. Candidate discovery joins -/// wallet keys against DML voting fields only, so a key whose address once -/// funded this node's collateral looks unused there; this is the check that -/// stops it before funding. From v3 (ExtAddr) entries Core also rejects a -/// payout script equal to the collateral script (`bad-protx-payee-reuse`); -/// this payload is version 2, so that arm applies exactly when the ENTRY is -/// v3 — Core gates on `max(entry version, payload version)`, and an entry -/// advertises extended net info exactly when it is v3+. +/// key destination (`bad-protx-collateral-reuse`) — the rule keeping the +/// collateral key off an online voting server. `ExtractDestination` maps +/// both a P2PKH collateral and a valid P2PK one to a `PKHash` — the latter +/// by hashing the public key in its original (compressed or uncompressed) +/// script serialization — so both forms are extracted here. Candidate +/// discovery joins wallet keys against DML voting fields only, so a key +/// whose address once funded this node's collateral looks unused there; +/// this is the check that stops it before funding. From v3 (ExtAddr) +/// entries Core also rejects a payout script equal to the collateral script +/// (`bad-protx-payee-reuse`); this payload is version 2, so that arm +/// applies exactly when the ENTRY is v3 — Core gates on `max(entry version, +/// payload version)`, and an entry advertises extended net info exactly +/// when it is v3+. pub(crate) fn ensure_collateral_not_reused( collateral_script: &ScriptBuf, owner_key_hash: &PubkeyHash, @@ -298,14 +337,27 @@ pub(crate) fn ensure_collateral_not_reused( script_payout: &ScriptBuf, entry_is_v3: bool, ) -> Result<(), PlatformWalletError> { - if let Some(collateral_key) = p2pkh_script_hash(collateral_script.as_bytes()) { - if collateral_key == owner_key_hash.to_byte_array() - || collateral_key == *final_voting_key_hash - { + let collateral_key = p2pkh_script_hash(collateral_script.as_bytes()).or_else(|| { + collateral_script + .p2pk_public_key() + .map(|public_key| public_key.pubkey_hash().to_byte_array()) + }); + if let Some(collateral_key) = collateral_key { + // The owner key is immutable, so its collision has no remedy the + // caller can apply — unlike the voting key, which can be re-chosen. + if collateral_key == owner_key_hash.to_byte_array() { + return Err(PlatformWalletError::InvalidParameter( + "the masternode's immutable owner key is its collateral address — consensus \ + rejects reusing the collateral key (`bad-protx-collateral-reuse`), so this \ + masternode cannot be updated with a ProUpRegTx" + .to_string(), + )); + } + if collateral_key == *final_voting_key_hash { return Err(PlatformWalletError::InvalidParameter( - "the chosen voting key (or the owner key) is the masternode's collateral \ - address — consensus rejects reusing the collateral key \ - (`bad-protx-collateral-reuse`); pick a different voting key" + "the chosen voting key is the masternode's collateral address — consensus \ + rejects reusing the collateral key (`bad-protx-collateral-reuse`); pick a \ + different voting key" .to_string(), )); } @@ -727,13 +779,37 @@ mod tests { /// public orchestrator uses, then funded, signed and broadcast. The /// public function adds only the SPV summaries read, the txid-bound /// ProRegTx fetch and the collateral resolution around this. - #[test] - fn assembles_and_signs_through_the_prepare_wiring() { - use dashcore::blockdata::transaction::special_transaction::provider_registration::{ - ProviderMasternodeType, ProviderRegistrationPayload, - }; + /// A registration payload for masternode `0x11` with an internal + /// collateral and the test owner key — the ProRegTx side of the + /// assembly fixtures. + fn test_registration() -> ProviderRegistrationPayload { + use dashcore::blockdata::transaction::special_transaction::provider_registration::ProviderMasternodeType; use dashcore::OutPoint; + ProviderRegistrationPayload { + version: ProviderRegistrationPayload::CURRENT_VERSION, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }, + service_address: "10.0.0.17:9999".parse().expect("socket address"), + owner_key_hash: owner_key_hash(), + operator_public_key: BLSPublicKey::from([0x11; 48]), + voting_key_hash: PubkeyHash::from_byte_array([0x11; 20]), + operator_reward: 0, + script_payout: ScriptBuf::new(), + inputs_hash: InputsHash::all_zeros(), + signature: vec![], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + } + } + + #[test] + fn assembles_and_signs_through_the_prepare_wiring() { let wallet = crate::test_support::sync_test_platform_wallet(); let derived_operator: [u8; 48] = wallet @@ -759,26 +835,7 @@ mod tests { new_voting_key_index: Some(0), payout_address: payout_address.to_string(), }; - let registration = ProviderRegistrationPayload { - version: ProviderRegistrationPayload::CURRENT_VERSION, - masternode_type: ProviderMasternodeType::Regular, - masternode_mode: 0, - collateral_outpoint: OutPoint { - txid: Txid::all_zeros(), - vout: 0, - }, - service_address: "10.0.0.17:9999".parse().expect("socket address"), - owner_key_hash: owner_key_hash(), - operator_public_key: BLSPublicKey::from([0x11; 48]), - voting_key_hash: PubkeyHash::from_byte_array([0x11; 20]), - operator_reward: 0, - script_payout: ScriptBuf::new(), - inputs_hash: InputsHash::all_zeros(), - signature: vec![], - platform_node_id: None, - platform_p2p_port: None, - platform_http_port: None, - }; + let registration = test_registration(); let collateral = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0xAB; 20])); let placeholder = assemble_update_registrar_placeholder( @@ -885,6 +942,50 @@ mod tests { }); } + /// Regression for the review's async-context panic: key derivation + /// takes the wallet-manager lock via tokio's `blocking_read`, which + /// panics on a runtime worker thread — where the FFI's + /// `block_on_worker` polls the orchestrator. Driving the same + /// assembly seam the orchestrator awaits from inside a multi-thread + /// runtime must yield the payload, not abort. + #[test] + fn assembly_derives_keys_from_an_async_context_without_panicking() { + // The fixture hands back an `Arc`; the orchestrator moves an owned + // clone of the wallet handle into the assembly task. + let wallet = PlatformWallet::clone(&crate::test_support::sync_test_platform_wallet()); + let summaries = vec![masternode(0x11)]; + let params = MasternodeUpdateRegistrarParams { + pro_tx_hash: [0x11; 32], + new_operator_key_index: Some(0), + new_voting_key_index: Some(0), + payout_address: DashAddress::dummy(Network::Mainnet, 3).to_string(), + }; + let collateral = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0xAB; 20])); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime"); + let (placeholder, _owner) = runtime + .block_on(async move { + // A spawned task (not just `block_on`) — the assembly must + // survive polling on a worker in an async execution + // context, exactly like the FFI drives it. + tokio::spawn(assemble_update_registrar_placeholder_from_async( + wallet, + summaries, + test_registration(), + collateral, + params, + owner(), + )) + .await + .expect("assembly task must not panic in an async context") + }) + .expect("assembly succeeds"); + assert_eq!(placeholder.pro_tx_hash, Txid::from_byte_array([0x11; 32])); + } + #[tokio::test] async fn builds_signs_and_broadcasts_a_pro_up_reg_tx() { let (wallet_manager, wallet_id, generation, signer) = @@ -1053,6 +1154,32 @@ mod review_tests { ensure_collateral_not_reused(&unrelated, &owner, &voting, &payout, true) .expect("an unrelated collateral passes both gates"); + // The two collisions are distinct conditions: re-choosing the + // voting key clears one, while the immutable owner key's collision + // has no remedy — the messages must say so. + let owner_message = ensure_collateral_not_reused( + &ScriptBuf::new_p2pkh(&owner), + &owner, + &voting, + &payout, + false, + ) + .expect_err("owner collision") + .to_string(); + assert!( + owner_message.contains("cannot be updated"), + "the owner-key collision must not suggest re-choosing the voting key: \ + {owner_message}" + ); + let voting_message = + ensure_collateral_not_reused(&voting_collateral, &owner, &voting, &payout, false) + .expect_err("voting collision") + .to_string(); + assert!( + voting_message.contains("pick a different voting key"), + "the voting-key collision is remedied by re-choosing: {voting_message}" + ); + // Payout == collateral: Core applies this arm only from v3 // (ExtAddr) entries — a P2SH collateral carries no key id, so only // the gated script-equality check can fire. @@ -1064,6 +1191,67 @@ mod review_tests { assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); } + /// Core's `ExtractDestination` also converts a valid P2PK collateral + /// script to a `PKHash` — hashing the public key in its original + /// (compressed or uncompressed) serialization — and `CheckProUpRegTx` + /// compares that hash against the owner and final voting keys. A P2PK + /// collateral paid to either key must be refused, in both + /// serializations. + #[test] + fn p2pk_collateral_reuse_is_refused() { + let secp = Secp256k1::new(); + let secret = SecretKey::from_byte_array(&[7u8; 32]).expect("valid scalar"); + let inner = SecpPublicKey::from_secret_key(&secp, &secret); + let compressed = dashcore::PublicKey::new(inner); + let uncompressed = dashcore::PublicKey::new_uncompressed(inner); + let payout = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0x33; 20])); + let other_owner = PubkeyHash::from_byte_array([0x11; 20]); + let other_voting = [0x22u8; 20]; + + // Compressed P2PK collateral at the chosen voting key. + let compressed_collateral = ScriptBuf::new_p2pk(&compressed); + let voting = compressed.pubkey_hash().to_byte_array(); + let err = ensure_collateral_not_reused( + &compressed_collateral, + &other_owner, + &voting, + &payout, + false, + ) + .expect_err("a compressed-P2PK collateral at the voting key is refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // Uncompressed P2PK collateral at the owner key — hashed over the + // key's own 65-byte serialization, which differs from the + // compressed hash. + let uncompressed_collateral = ScriptBuf::new_p2pk(&uncompressed); + let owner = uncompressed.pubkey_hash(); + assert_ne!( + owner.to_byte_array(), + voting, + "the two serializations must hash differently for this test to mean anything" + ); + let err = ensure_collateral_not_reused( + &uncompressed_collateral, + &owner, + &other_voting, + &payout, + false, + ) + .expect_err("an uncompressed-P2PK collateral at the owner key is refused"); + assert!(matches!(err, PlatformWalletError::InvalidParameter(_))); + + // A P2PK collateral for an unrelated key passes. + ensure_collateral_not_reused( + &compressed_collateral, + &other_owner, + &other_voting, + &payout, + false, + ) + .expect("an unrelated P2PK collateral passes"); + } + /// The collateral script comes from an output index inside a fetched /// transaction — bounds-checked, never a panic on hostile data. #[test] From d7c83181cb78b9d35435e2d4bb590db13ea1843a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 10 Sep 2026 17:45:40 +0700 Subject: [PATCH 08/13] fix(platform-wallet): let a registrar update re-assert the target's own operator key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core's CheckProUpRegTx duplicate-key checks both exclude the node being updated (HasOperatorKeyUnderAnyScheme's self parameter, and the proTxHash != otherDmn->proTxHash guard on the unique-property lookup), so re-asserting the masternode's current operator key — a retry, or a voting-only change re-selecting the same operator index — is consensus valid. The wallet preflight refused it as a duplicate; it now skips the target entry while still rejecting clashes with every other masternode. Co-Authored-By: Claude Fable 5 --- .../src/masternode/update_registrar.rs | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index b01c0a2f702..950dcef8fcb 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -231,7 +231,7 @@ pub(crate) fn assemble_update_registrar_placeholder( .legacy_public_key_bytes .as_deref() .and_then(|b| b.try_into().ok()); - ensure_operator_key_unused(summaries, &bytes, legacy.as_ref())?; + ensure_operator_key_unused(summaries, ¶ms.pro_tx_hash, &bytes, legacy.as_ref())?; bytes } None => normalize_operator_key_to_basic( @@ -433,19 +433,26 @@ pub(crate) fn resolve_owner_payout_script( Ok(script) } -/// Refuse a candidate operator key already registered to any masternode — -/// operator keys are consensus-unique across the whole list, so a duplicate -/// would make the ProUpRegTx invalid. The list may hold either -/// serialization of a key, so both forms are checked. +/// Refuse a candidate operator key already registered to any masternode +/// other than `target` — operator keys are consensus-unique across the +/// list, so such a duplicate would make the ProUpRegTx invalid. The target +/// itself is exempt: both of Core's `CheckProUpRegTx` duplicate-key checks +/// exclude the node being updated, so re-asserting its own current key +/// (say, on a retry) is valid. The list may hold either serialization of a +/// key, so both forms are checked. pub(crate) fn ensure_operator_key_unused( summaries: &[MasternodeListSummary], + target: &[u8; 32], 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) - }); + let clash = summaries + .iter() + .filter(|entry| entry.pro_tx_hash != *target) + .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 \ @@ -754,23 +761,31 @@ mod tests { } /// Operator keys are consensus-unique across the list — a candidate in - /// use (under either serialization) must be refused before signing. + /// use by any OTHER masternode (under either serialization) must be + /// refused before signing, while the target's own current key passes: + /// Core's duplicate-key checks exclude the node being updated. #[test] fn used_operator_keys_are_refused() { let mut entry = masternode(0x11); entry.operator_public_key = [0xAA; 48]; let summaries = vec![entry]; + let other_target = [0x99; 32]; - let err = ensure_operator_key_unused(&summaries, &[0xAA; 48], None) + let err = ensure_operator_key_unused(&summaries, &other_target, &[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"); + let err = + ensure_operator_key_unused(&summaries, &other_target, &[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])) + ensure_operator_key_unused(&summaries, &other_target, &[0xBB; 48], Some(&[0xCC; 48])) .expect("an unused key passes"); + + // The target re-asserting its own registered key is not a clash. + ensure_operator_key_unused(&summaries, &[0x11; 32], &[0xAA; 48], None) + .expect("the target's own current operator key passes"); } /// The full prepare wiring below the network fetches — entry lookup, From 939c9c6513f43e18146e4f6288879282ea6d3080 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 11 Sep 2026 23:18:48 +0700 Subject: [PATCH 09/13] fix(platform-wallet): report a concurrently removed wallet instead of panicking in provider key derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_context hands the FFI an Arc whose underlying key wallet the host can remove while the candidates extern awaits the masternode list (Swift's providerKeyCandidates runs detached from the deletion admission gate). derive_provider_key_at_index then hit the read guard's expect("wallet exists in guard"), aborting at the non-unwinding C boundary. The guard gains a fallible try_wallet(), and the derivation resolves the wallet once through it, returning WalletNotFound for the race — covering the registrar assembly path the same way. Regression: removing the wallet after resolving the handle makes provider_key_candidates return the error. Co-Authored-By: Claude Fable 5 --- .../src/masternode/key_candidates.rs | 23 +++++++++++++++++++ .../src/wallet/platform_wallet.rs | 9 ++++++++ .../src/wallet/provider_key_at_index.rs | 20 ++++++++++------ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/key_candidates.rs b/packages/rs-platform-wallet/src/masternode/key_candidates.rs index 56c11090b9f..1794cc352e9 100644 --- a/packages/rs-platform-wallet/src/masternode/key_candidates.rs +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -182,6 +182,29 @@ mod tests { assert_eq!(candidates[0].public_key_bytes.len(), 33); } + /// Regression for the review's wallet-removal race: the candidates + /// extern resolves the `PlatformWallet` handle, awaits the masternode + /// list, and only then derives — a window in which the host can + /// delete the wallet. Derivation must surface that as an error + /// through the fallible wallet lookup, not abort on the guard's + /// `expect("wallet exists in guard")` at the non-unwinding C + /// boundary. + #[test] + fn removed_wallet_is_an_error_not_a_panic() { + let wallet = test_wallet(); + let summaries = vec![masternode(0x55)]; + + wallet + .wallet_manager() + .blocking_write() + .remove_wallet(&wallet.wallet_id()) + .expect("the test wallet is registered"); + + let err = provider_key_candidates(&wallet, &summaries, ProviderKeyKind::Operator, 3) + .expect_err("a removed wallet must come back as an error"); + assert!(matches!(err, PlatformWalletError::WalletNotFound(_))); + } + #[test] fn unsupported_kinds_zero_counts_and_oversized_counts_are_handled() { let wallet = test_wallet(); diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index aa97d44a223..b68c2c9b453 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1967,6 +1967,15 @@ impl<'a> WalletStateReadGuard<'a> { .get_wallet(&self.wallet_id) .expect("wallet exists in guard") } + + /// Fallible form of [`wallet`](Self::wallet): `None` when the wallet + /// was removed from the manager after this guard's `PlatformWallet` + /// handle was resolved. Paths reachable from the FFI while the host + /// concurrently deletes the wallet must use this — the panicking + /// accessor would abort at the non-unwinding C boundary. + pub fn try_wallet(&self) -> Option<&Wallet> { + self.guard.get_wallet(&self.wallet_id) + } } impl Deref for WalletStateReadGuard<'_> { diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 629a7c574cc..2836b6a226b 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -433,6 +433,15 @@ impl PlatformWallet { // already ran on the FFI side and produced `resolved_seed` before // we were called. let state = self.state_blocking(); + // Resolved fallibly, once: the host can remove the wallet while an + // FFI caller still holds this `PlatformWallet` handle (the + // candidates extern awaits the masternode list between resolving + // the handle and deriving), and that race must surface as an + // error — the guard's panicking accessor would abort at the + // non-unwinding C boundary. + let in_process_wallet = state + .try_wallet() + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; // Raw 64-byte BIP39 seed — the exact input the account's curve // master consumes (#879). Only obtained when a seed-bearing path @@ -451,7 +460,7 @@ impl PlatformWallet { // seed. `None` for a watch-only / external-signable // wallet (no resident seed), which the caller must // instead service with a `resolved_seed`. - let raw = state.wallet().wallet_seed_bytes().ok_or_else(|| { + let raw = in_process_wallet.wallet_seed_bytes().ok_or_else(|| { PlatformWalletError::KeyDerivation( "wallet has no resident seed (external-signable / watch-only); a \ resolved seed is required to derive this provider key" @@ -468,8 +477,7 @@ impl PlatformWallet { match kind { ProviderKeyKind::Operator => { - let account = state - .wallet() + let account = in_process_wallet .accounts .bls_account_of_type(account_type) .ok_or_else(|| { @@ -523,8 +531,7 @@ impl PlatformWallet { // Existence check — a missing account is a caller error, // not a derivation failure (the seed-based entry point below // needs no account state). - if state - .wallet() + if in_process_wallet .accounts .eddsa_account_of_type(account_type) .is_none() @@ -576,8 +583,7 @@ impl PlatformWallet { }) } ProviderKeyKind::Owner | ProviderKeyKind::Voting => { - let account = state - .wallet() + let account = in_process_wallet .accounts .account_of_type(account_type) .ok_or_else(|| { From c3da0bd070e2f91faeb1b4dfa9c5a0314b9a0dd0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 05:35:06 +0700 Subject: [PATCH 10/13] fix(platform-wallet): tighten service-value preflight and owner-secret hygiene from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review suggestions, each verified against Core's develop sources: - Core's CNetAddr::IsValid runs before (and independent of) the routability requirement, so the unspecified and broadcast IPv4 addresses are invalid on every network — the values validator now rejects 0.0.0.0 and 255.255.255.255 even on regtest, keeping the regtest exemption for private-but-valid addresses only. - The temporary secp256k1 SecretKey locals in owner signing and owner verification are Copy with no erasing destructor; both are now scrubbed with non_secure_erase() right after use, matching the provider-key derivation code. - New test drives an ExtNetInfo entry with secondary IPv4 + IPv6 endpoints and domain/invalid entries through the summary conversion, asserting every socket endpoint is lifted and non-socket entries are skipped — then that a lifted secondary endpoint actually collides in the service-uniqueness preflight. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet/src/masternode/list.rs | 91 +++++++++++++++++++ .../src/masternode/update_registrar.rs | 12 ++- .../src/masternode/update_service.rs | 22 +++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/list.rs b/packages/rs-platform-wallet/src/masternode/list.rs index db7a49ba57c..e96ad1931e1 100644 --- a/packages/rs-platform-wallet/src/masternode/list.rs +++ b/packages/rs-platform-wallet/src/masternode/list.rs @@ -337,6 +337,97 @@ mod tests { assert!(find_in_summaries(&list, &MasternodeListQuery::ProTxHash([9u8; 32])).is_empty()); } + /// The extended-map conversion is what feeds the service-uniqueness + /// preflight its secondary endpoints: every socket entry under every + /// purpose must be lifted (Core's unique-property index registers each + /// one individually), while domain / invalid entries — which have no + /// socket form — are skipped. The lifted secondary endpoint must then + /// actually collide in the values validator. + #[test] + fn summary_lifts_every_extended_endpoint() { + use super::super::update_service::validate_update_service_values; + use dashcore::sml::masternode_list_entry::net_info::{ + Bip155Network, ExtNetInfo, NetInfoEntry, NetInfoPurpose, + }; + use dashcore::Network; + + let mut ipv6 = [0u8; 16]; + ipv6[0] = 0x20; + ipv6[1] = 0x01; + ipv6[15] = 0x01; + let entry = MasternodeListEntry { + version: 2, + pro_reg_tx_hash: ProTxHash::from_byte_array([8u8; 32]), + confirmed_hash: None, + service_address: MasternodeNetInfo::Extended(ExtNetInfo { + version: 1, + purposes: vec![ + ( + NetInfoPurpose::CoreP2P, + vec![ + NetInfoEntry::Service { + network: Bip155Network::Ipv4, + addr: vec![34, 214, 48, 68], + port: 19999, + }, + NetInfoEntry::Service { + network: Bip155Network::Ipv4, + addr: vec![34, 214, 48, 69], + port: 29999, + }, + NetInfoEntry::Domain { + host: "node.example".to_string(), + port: 19999, + }, + ], + ), + ( + NetInfoPurpose::PlatformHttps, + vec![ + NetInfoEntry::Service { + network: Bip155Network::Ipv6, + addr: ipv6.to_vec(), + port: 443, + }, + NetInfoEntry::Invalid, + ], + ), + ], + }), + operator_public_key: BLSPublicKey::from([9u8; 48]), + key_id_voting: PubkeyHash::from_byte_array([5u8; 20]), + is_valid: true, + mn_type: EntryMasternodeType::Regular, + }; + + let summary = MasternodeListSummary::from_entry(&entry); + assert!(summary.has_extended_net_info); + assert_eq!( + summary.service_addresses, + vec![ + "34.214.48.68:19999".parse::().unwrap(), + "34.214.48.69:29999".parse::().unwrap(), + "[2001::1]:443".parse::().unwrap(), + ], + "every socket endpoint under every purpose is lifted; \ + domain and invalid entries are skipped" + ); + + // A caller-supplied value equal to the extended entry's SECONDARY + // endpoint is refused by the uniqueness preflight — the join this + // converter exists to feed. + let target = masternode(0x60); + let summaries = vec![target.clone(), summary]; + let values = super::super::update_service::UpdateServiceValues { + service_address: "34.214.48.69:29999".to_string(), + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + }; + validate_update_service_values(Network::Testnet, &target, &values, &summaries) + .expect_err("another entry's secondary extended endpoint must collide"); + } + #[test] fn finds_by_ip_with_or_without_port() { let list = vec![masternode(1), masternode(2)]; diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index 950dcef8fcb..481192ce27d 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -380,12 +380,16 @@ pub(crate) fn verify_owner_secret( owner: &OwnerSecret, ) -> Result<(), PlatformWalletError> { let secp = Secp256k1::new(); - let secret = SecretKey::from_byte_array(&owner.secret).map_err(|_| { + let mut 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); + // `SecretKey` is `Copy` with no erasing destructor — scrub this local + // scalar (the `Zeroizing` on `OwnerSecret.secret` covers only the + // caller's bytes) as soon as the public key is out, on every path. + secret.non_secure_erase(); let serialized: Vec = if owner.compressed { public.serialize().to_vec() } else { @@ -536,7 +540,7 @@ pub(crate) fn owner_compact_signature( owner: &OwnerSecret, ) -> Result, PlatformWalletError> { let secp = Secp256k1::new(); - let secret = SecretKey::from_byte_array(&owner.secret).map_err(|_| { + let mut secret = SecretKey::from_byte_array(&owner.secret).map_err(|_| { PlatformWalletError::InvalidParameter( "the owner key is not a valid secp256k1 private key".to_string(), ) @@ -544,6 +548,10 @@ pub(crate) fn owner_compact_signature( let digest = payload.base_payload_hash().to_byte_array(); let message = Message::from_digest(digest); let recoverable = secp.sign_ecdsa_recoverable(&message, &secret); + // `SecretKey` is `Copy` with no erasing destructor — scrub this local + // scalar (the `Zeroizing` on `OwnerSecret.secret` covers only the + // caller's bytes) as soon as the signature is made. + secret.non_secure_erase(); 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 }); diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 9b98ec69dc4..6d2b9d8a75c 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -374,6 +374,16 @@ pub(crate) fn validate_update_service_values( "the service port must not be 0".to_string(), )); } + // `CNetAddr::IsValid` runs before (and independent of) the network's + // routability requirement, so the unspecified and broadcast addresses + // are rejected on EVERY network — the regtest exemption below covers + // only private-but-valid addresses. + if ip.is_unspecified() || ip.is_broadcast() { + return Err(PlatformWalletError::InvalidParameter(format!( + "service address {ip} is not a valid address on any network \ + (`bad-protx-netinfo-entry`)" + ))); + } if network != Network::Regtest && !ipv4_is_routable(ip) { return Err(PlatformWalletError::InvalidParameter(format!( "service address {ip} is not routable — consensus rejects reserved and private \ @@ -1133,6 +1143,18 @@ mod tests { check(Network::Regtest, "10.0.0.5:19999").expect("regtest does not require routability"); + // `CNetAddr::IsValid` runs on every network, before the + // routability gate: the unspecified and broadcast addresses are + // refused even where routability is not required. + for service in ["0.0.0.0:19999", "255.255.255.255:19999"] { + for network in [Network::Regtest, Network::Testnet] { + assert!( + check(network, service).is_err(), + "{service} must be refused on {network:?}" + ); + } + } + check(Network::Mainnet, "34.214.48.68:9999") .expect("mainnet requires the default Core P2P port"); check(Network::Mainnet, "34.214.48.68:19999") From 03a1addd4d926e7d57996371e7d355b1a873597c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 12 Sep 2026 07:50:29 +0700 Subject: [PATCH 11/13] fix(platform-wallet): guard execute-path broadcasts against wallet teardown, refuse zero platform ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The execute forms of the registrar and service updates signed and then broadcast directly, without the generation lifecycle protection the prepare-then-broadcast flow gets from core_wallet_broadcast_signed_transaction — so a wallet removed (or re-created) between signing and the send could still publish its transaction (dashpay/platform#4185's shape). All three execute paths now broadcast through a shared helper that holds generation_payment_guard across the liveness check and the send, abandoning and refusing when the generation is no longer live, with a regression that removes the wallet after signing and asserts nothing reaches the broadcaster. The explicit-values validator also refuses Some(0) for either platform port before funding. Not a consensus mirror: Core's CheckProviderNetworkFields accepts a single zero platform port in a v2 payload off mainnet (only both-zero collides in its dup-ports equality) — but no node can serve on port 0, so a supplied zero is an input error caught before money moves. Co-Authored-By: Claude Fable 5 --- .../src/masternode/update_registrar.rs | 2 +- .../src/masternode/update_service.rs | 101 +++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index 481192ce27d..e72129e7eef 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -85,7 +85,7 @@ pub async fn execute_masternode_update_registrar Result { let signed = prepare_masternode_update_registrar(wallet, spv, params, owner, signer).await?; - wallet.core().broadcast_finalized_transaction(&signed).await + super::update_service::broadcast_special_transaction_guarded(wallet.core(), &signed).await } /// Everything [`execute_masternode_update_registrar`] does except the diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 6d2b9d8a75c..5d65d809890 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -87,7 +87,38 @@ pub async fn execute_masternode_update_service Result { let signed = prepare_masternode_update_service(wallet, spv, params, operator_secret, signer).await?; - wallet.core().broadcast_finalized_transaction(&signed).await + broadcast_special_transaction_guarded(wallet.core(), &signed).await +} + +/// Broadcast a prepared masternode special transaction under the wallet +/// generation's lifecycle gate. The prepare paths drop every manager lock +/// across their awaits (network fetches, the external signer), so the host +/// can remove — or re-create — the wallet between signing and this send; +/// broadcasting then would publish a dead generation's transaction, which +/// can conflict with inputs a re-created generation has since selected +/// (`dashpay/platform#4185`). The gate is held across BOTH the liveness +/// check and the send, so a teardown cannot interleave between them — +/// the same protection `core_wallet_broadcast_signed_transaction` gives +/// the prepare-then-broadcast flow. On a dead generation the transaction +/// is abandoned (generation-bound, so a logged no-op after a genuine +/// removal) and the send refused. +pub(crate) async fn broadcast_special_transaction_guarded( + core: &CoreWallet, + signed: &SignedCoreTransaction, +) -> Result +where + B: TransactionBroadcaster + ?Sized, +{ + let _lifecycle = core.generation_payment_guard().await; + if !core.is_current_generation().await { + core.abandon_transaction(signed).await; + return Err(PlatformWalletError::WalletNotFound( + "the wallet was removed (or re-created) while the transaction was being \ + prepared; it was NOT broadcast and its funding reservation was reconciled" + .to_string(), + )); + } + core.broadcast_finalized_transaction(signed).await } /// Everything [`execute_masternode_update_service`] does except the @@ -193,7 +224,7 @@ pub async fn execute_masternode_update_service_with_values Date: Sat, 12 Sep 2026 16:32:12 +0700 Subject: [PATCH 12/13] fix(platform-wallet): keep operator rotations inside the tracked pool window, unblock Swift's cooperative executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review suggestions: - Candidate discovery derives up to 256 indices, but the managed provider pool — and provider_masternode_txs_blocking's derive-and-compare ownership scan with it — tracks only through max(highest_generated + 1, 20). A rotation to a beyond-window index would broadcast a valid key the wallet could never attribute back to itself. The registrar assembly now refuses untracked indices before funding, via a shared PlatformWallet::provider_operator_tracked_window (the window constant is now shared with the ownership scan), with a beyond-window regression test. - Swift's providerKeyCandidates ran its synchronous FFI call in Task.detached, which still executes on the cooperative pool and parks a cooperative worker behind the wallet-manager lock. It now runs on a dedicated GCD queue and resumes through a continuation — the pattern pollQueue and the DPNS active-contests queue document. - provider_key_candidates documents its blocking-only contract: a Panics section for async-context callers and spawn_blocking guidance, matching wallet_masternodes_blocking's warning. Co-Authored-By: Claude Fable 5 --- .../src/manager/accessors.rs | 3 +- .../src/masternode/key_candidates.rs | 13 +++ .../src/masternode/update_registrar.rs | 51 +++++++++++ .../src/wallet/provider_key_at_index.rs | 41 +++++++++ ...tformWalletManagerMasternodeRotation.swift | 90 ++++++++++++------- 5 files changed, 162 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 316b66820e8..a78395f7652 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1211,10 +1211,9 @@ impl PlatformWalletManager

{ &self, wallet_id: &WalletId, ) -> Option { + use crate::wallet::provider_key_at_index::PROVIDER_KEY_WINDOW; use key_wallet::managed_account::address_pool::PublicKeyType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - // Default provider-key pre-derivation / scan window. - const PROVIDER_KEY_WINDOW: u32 = 20; // Scope the wallet-manager read lock so it's released before we // acquire the SPV client / engine locks for the DML snapshot — the diff --git a/packages/rs-platform-wallet/src/masternode/key_candidates.rs b/packages/rs-platform-wallet/src/masternode/key_candidates.rs index 1794cc352e9..6b403aa5cfd 100644 --- a/packages/rs-platform-wallet/src/masternode/key_candidates.rs +++ b/packages/rs-platform-wallet/src/masternode/key_candidates.rs @@ -40,6 +40,19 @@ pub struct ProviderKeyCandidate { /// matched against entry voting key ids); other kinds are refused — owner /// keys are immutable and never candidates, platform-node keys need the /// seed. +/// +/// Blocking, like +/// [`wallet_masternodes_blocking`](crate::PlatformWalletManager::wallet_masternodes_blocking): +/// every derivation takes the wallet-manager lock via tokio's +/// `blocking_read`, so call this from a plain or blocking thread — from +/// async code, wrap it in `tokio::task::spawn_blocking`. (The candidates +/// FFI extern awaits the masternode list on the runtime and then calls +/// this on its plain calling thread for exactly this reason.) +/// +/// # Panics +/// Called on an async runtime worker with a supported kind and a nonzero +/// `count` — tokio's `blocking_read` panics in an asynchronous execution +/// context, even uncontended, instead of returning an error. pub fn provider_key_candidates( wallet: &PlatformWallet, summaries: &[MasternodeListSummary], diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index e72129e7eef..3ee501e098a 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -212,6 +212,22 @@ pub(crate) fn assemble_update_registrar_placeholder( // the caller rotates, the live entry's values where it keeps. let operator_public_key = match params.new_operator_key_index { Some(index) => { + // Candidate discovery derives up to 256 indices, but the + // managed provider pool — and with it the ownership scan that + // later attributes an observed operator key back to a wallet + // index — only tracks up to its `highest_generated` watermark + // (floored at the default window). A beyond-window key would + // sign and broadcast fine and then never be recognized as the + // wallet's own, defeating the point of rotating into a wallet + // key. Refuse before any funding. + let tracked = wallet.provider_operator_tracked_window()?; + if index >= tracked { + return Err(PlatformWalletError::InvalidParameter(format!( + "operator key index {index} is outside the wallet's tracked provider \ + pool (indices 0..{tracked}): the key would derive, but the wallet \ + could never recognize it as its own afterwards — pick a lower index" + ))); + } let derived = wallet.derive_provider_key_at_index( ProviderKeyKind::Operator, index, @@ -965,6 +981,41 @@ mod tests { }); } + /// A rotation must not select an operator index the managed provider + /// pool doesn't track: the derive-and-compare ownership scan + /// (`provider_masternode_txs_blocking`) covers only the pool window, + /// so a beyond-window key would broadcast fine and then never be + /// attributed back to this wallet. + #[test] + fn beyond_window_operator_indices_are_refused() { + let wallet = crate::test_support::sync_test_platform_wallet(); + let window = wallet + .provider_operator_tracked_window() + .expect("tracked window"); + let summaries = vec![masternode(0x11)]; + let params = MasternodeUpdateRegistrarParams { + pro_tx_hash: [0x11; 32], + new_operator_key_index: Some(window), + new_voting_key_index: None, + payout_address: DashAddress::dummy(Network::Mainnet, 3).to_string(), + }; + let collateral = ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0xAB; 20])); + + let err = assemble_update_registrar_placeholder( + &wallet, + &summaries, + &test_registration(), + &collateral, + ¶ms, + &owner(), + ) + .expect_err("an untracked operator index is refused before funding"); + assert!( + err.to_string().contains("tracked provider pool"), + "unexpected error: {err}" + ); + } + /// Regression for the review's async-context panic: key derivation /// takes the wallet-manager lock via tokio's `blocking_read`, which /// panics on a runtime worker thread — where the FFI's diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index d8969053049..4c336ea06fe 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -94,6 +94,12 @@ use crate::error::PlatformWalletError; /// screen has a full first page to show from persistence alone. pub const PLATFORM_NODE_KEY_PREDERIVE_COUNT: u32 = 20; +/// Default provider-key pre-derivation / scan window: how many operator +/// (BLS) indices the managed provider pool tracks — and the +/// derive-and-compare ownership scan covers — for a wallet whose pool was +/// never extended past registration. +pub const PROVIDER_KEY_WINDOW: u32 = 20; + /// Derive the first `count` platform-node (Ed25519) public keys from a /// **seed-bearing** [`Wallet`](key_wallet::wallet::Wallet), returning /// the 32-byte public key + 20-byte Tenderdash node id @@ -387,6 +393,41 @@ fn checked_operator_private_bytes_at( } impl PlatformWallet { + /// One past the highest operator-key index the wallet's managed + /// provider pool tracks: its `highest_generated` watermark plus one, + /// floored at [`PROVIDER_KEY_WINDOW`] — the exact window the + /// derive-and-compare ownership scan + /// (`provider_masternode_txs_blocking`) covers. A rotation must select + /// inside it: a beyond-window index derives a perfectly valid key that + /// the wallet's ownership lookup could never attribute back to this + /// wallet afterwards. + /// + /// Blocking (wallet-manager `blocking_read` — never call from an async + /// runtime worker); errors when the wallet was concurrently removed. + pub fn provider_operator_tracked_window(&self) -> Result { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let wm = self.wallet_manager().blocking_read(); + let info = wm + .get_wallet_info(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; + Ok(info + .core_wallet + .accounts + .provider_operator_keys + .as_ref() + .and_then(|acct| { + acct.managed_account_type() + .address_pools() + .iter() + .filter_map(|pool| pool.highest_generated) + .max() + }) + .map(|highest| highest.saturating_add(1)) + .unwrap_or(PROVIDER_KEY_WINDOW) + .max(PROVIDER_KEY_WINDOW)) + } + /// Derive this wallet's provider key of `kind` at `index`. /// /// Public-only when `resolved_seed` is `None` and `include_private` diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift index a61d2b8eb25..ff631d1681d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift @@ -47,11 +47,25 @@ extension PlatformWalletManager { // MARK: - Key candidates + /// Serializes the blocking candidates FFI call on a GCD worker rather + /// than a Swift cooperative-executor thread: the extern parks on the + /// masternode-list read and then derives candidates on the calling + /// thread behind the wallet-manager lock, so a contended lock or slow + /// list operation would otherwise park a cooperative worker for its + /// whole duration (same rationale as `pollQueue` and the DPNS + /// active-contests queue; a `Task.detached` closure still runs ON the + /// cooperative pool). + nonisolated private static let candidatesQueue = DispatchQueue( + label: "org.dash.platform-wallet.rotation-candidates", + qos: .userInitiated + ) + /// 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. + /// join), so it runs on [`candidatesQueue`] and resumes the async + /// caller through a continuation. public func providerKeyCandidates( walletId: Data, kind: RotationKeyKind, @@ -64,41 +78,49 @@ extension PlatformWalletManager { "Manager not configured, wallet id not 32 bytes, or count above the candidates maximum") } let handle = self.handle - return try await Task.detached(priority: .userInitiated) { () -> [ProviderKeyCandidate] in - var outEntries: UnsafeMutablePointer? - var outCount: UInt = 0 - let ffiResult = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - platform_wallet_manager_provider_key_candidates( - handle, - raw.baseAddress?.assumingMemoryBound(to: UInt8.self), - kind.rawValue, - count, - &outEntries, - &outCount) - } - let result = PlatformWalletResult(ffiResult) - guard result.isSuccess else { - throw PlatformWalletError(result: result) - } - guard let entries = outEntries, outCount > 0 else { return [] } - defer { platform_wallet_manager_free_provider_key_candidates(entries, outCount) } - return (0..) in + Self.candidatesQueue.async { + 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) } - var usedByTuple = entry.used_by_pro_tx_hash - let usedBy = entry.used - ? Swift.withUnsafeBytes(of: &usedByTuple) { Data($0) } - : nil - return ProviderKeyCandidate( - index: entry.index, - publicKey: publicKey, - address: entry.address.map { String(cString: $0) }, - usedByProTxHash: usedBy) + let result = PlatformWalletResult(ffiResult) + guard result.isSuccess else { + continuation.resume(throwing: PlatformWalletError(result: result)) + return + } + guard let entries = outEntries, outCount > 0 else { + continuation.resume(returning: []) + return + } + defer { platform_wallet_manager_free_provider_key_candidates(entries, outCount) } + let candidates = (0.. ProviderKeyCandidate in + let entry = entries[i] + var publicKeyTuple = entry.public_key + let publicKey = Swift.withUnsafeBytes(of: &publicKeyTuple) { + Data($0.prefix(Int(entry.public_key_len))) + } + var usedByTuple = entry.used_by_pro_tx_hash + let usedBy = entry.used + ? Swift.withUnsafeBytes(of: &usedByTuple) { Data($0) } + : nil + return ProviderKeyCandidate( + index: entry.index, + publicKey: publicKey, + address: entry.address.map { String(cString: $0) }, + usedByProTxHash: usedBy) + } + continuation.resume(returning: candidates) } - }.value + } } // MARK: - Registrar update (key rotation) From 92a3ebe17adc12432fb756c63f172c663809007d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 13 Sep 2026 03:08:48 +0700 Subject: [PATCH 13/13] fix(platform-wallet): bound voting rotations to the registered pool, keep rotation externs off Swift's cooperative executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voting-key ownership joins against the managed provider-voting pool's ACTUAL entries — key-wallet's voting matcher walks the pool's address index, and hosts join persisted address rows; there is no derive-and-compare scan like the operator side — so rotating to an unregistered index would sign and broadcast fine and then always show as an external voting key. The registrar assembly now refuses indices at or beyond PlatformWallet::provider_voting_tracked_window() before funding, with a beyond-pool regression test. The six registrar/service prepare and execute Swift wrappers also move off Task.detached (whose closures still run on the cooperative pool) onto a dedicated serial GCD queue behind a shared continuation helper, like providerKeyCandidates — kept on its own queue so a long-parked transaction never delays a picker refresh. Resolver and buffer lifetimes are unchanged: each body runs entirely on the queue. Co-Authored-By: Claude Fable 5 --- .../src/masternode/update_registrar.rs | 46 +++++++++++++++++ .../src/wallet/provider_key_at_index.rs | 34 +++++++++++++ ...tformWalletManagerMasternodeRotation.swift | 50 ++++++++++++++----- 3 files changed, 118 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/masternode/update_registrar.rs b/packages/rs-platform-wallet/src/masternode/update_registrar.rs index 3ee501e098a..d95c9a0dc35 100644 --- a/packages/rs-platform-wallet/src/masternode/update_registrar.rs +++ b/packages/rs-platform-wallet/src/masternode/update_registrar.rs @@ -257,6 +257,21 @@ pub(crate) fn assemble_update_registrar_placeholder( }; let voting_key_hash = match params.new_voting_key_index { Some(index) => { + // Voting ownership joins against the managed pool's ACTUAL + // entries — key-wallet's voting matcher walks the pool's + // address index, and hosts join persisted address rows; there + // is no derive-and-compare scan like the operator side. An + // unregistered index would sign and broadcast fine and then + // always show as an external voting key. Refuse before + // funding. + let tracked = wallet.provider_voting_tracked_window()?; + if index >= tracked { + return Err(PlatformWalletError::InvalidParameter(format!( + "voting key index {index} is outside the wallet's registered \ + provider-voting pool (indices 0..{tracked}): the wallet could never \ + recognize the rotated key as its own afterwards — pick a lower index" + ))); + } let derived = wallet.derive_provider_key_at_index(ProviderKeyKind::Voting, index, None, false)?; hash160::Hash::hash(&derived.public_key_bytes).to_byte_array() @@ -1014,6 +1029,37 @@ mod tests { err.to_string().contains("tracked provider pool"), "unexpected error: {err}" ); + + // Voting keys have the tighter bound: ownership joins against the + // managed pool's actual entries, so only registered indices pass. + let voting_window = wallet + .provider_voting_tracked_window() + .expect("voting window"); + assert!( + voting_window > 0, + "the fixture registers a provider-voting pool" + ); + // Operator index 0 is valid (the fixture's synthetic kept-key + // bytes would not reparse), so the voting gate is what fires. + let params = MasternodeUpdateRegistrarParams { + pro_tx_hash: [0x11; 32], + new_operator_key_index: Some(0), + new_voting_key_index: Some(voting_window), + payout_address: DashAddress::dummy(Network::Mainnet, 3).to_string(), + }; + let err = assemble_update_registrar_placeholder( + &wallet, + &summaries, + &test_registration(), + &collateral, + ¶ms, + &owner(), + ) + .expect_err("an unregistered voting index is refused before funding"); + assert!( + err.to_string().contains("registered provider-voting pool"), + "unexpected error: {err}" + ); } /// Regression for the review's async-context panic: key derivation diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 4c336ea06fe..b51d7c475d8 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -428,6 +428,40 @@ impl PlatformWallet { .max(PROVIDER_KEY_WINDOW)) } + /// One past the highest voting-key index the wallet's managed + /// provider-voting address pool holds an entry for. Unlike operator + /// keys — whose ownership is resolved by a derive-and-compare scan + /// over a floored window — voting ownership joins against the pool's + /// ACTUAL persisted entries (key-wallet's + /// `check_provider_voting_key_in_transaction_for_match` walks the + /// pool's address index, and hosts join their persisted address + /// rows), so a rotation must select an index the pool registered. + /// + /// Blocking (wallet-manager `blocking_read` — never call from an async + /// runtime worker); errors when the wallet was concurrently removed. + pub fn provider_voting_tracked_window(&self) -> Result { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let wm = self.wallet_manager().blocking_read(); + let info = wm + .get_wallet_info(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; + Ok(info + .core_wallet + .accounts + .provider_voting_keys + .as_ref() + .and_then(|acct| { + acct.managed_account_type() + .address_pools() + .iter() + .filter_map(|pool| pool.highest_generated) + .max() + }) + .map(|highest| highest.saturating_add(1)) + .unwrap_or(0)) + } + /// Derive this wallet's provider key of `kind` at `index`. /// /// Public-only when `resolved_seed` is `None` and `include_private` diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift index ff631d1681d..3fa8392474b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodeRotation.swift @@ -125,6 +125,32 @@ extension PlatformWalletManager { // MARK: - Registrar update (key rotation) + /// Serial queue for the rotation transaction externs: prepare and + /// execute both park the calling thread through the FFI (network + /// reads, key derivation, resolver-served signing and, for execute + /// paths, the broadcast), so they must park a GCD worker — never a + /// Swift cooperative-executor thread (a `Task.detached` closure still + /// runs ON the cooperative pool). Separate from [`candidatesQueue`] so + /// a long-parked transaction never delays a picker refresh. + nonisolated private static let rotationTransactionQueue = DispatchQueue( + label: "org.dash.platform-wallet.rotation-transactions", + qos: .userInitiated + ) + + /// Run one synchronous rotation FFI call on + /// [`rotationTransactionQueue`], resuming the async caller through a + /// continuation. `body` runs entirely on the queue — resolvers and + /// borrowed buffers stay alive inside it until the FFI call returns. + private static func onRotationQueue( + _ body: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + rotationTransactionQueue.async { + continuation.resume(with: Result(catching: body)) + } + } + } + /// Rotate a wallet-owned masternode's operator and/or voting key to /// fresh wallet keys with an owner-signed ProUpRegTx. Pure bridge — the /// preflights (owner key vs the ProRegTx, network-wide operator-key @@ -153,7 +179,7 @@ extension PlatformWalletManager { } try requireNoEmbeddedNul(payoutAddress, "payout address") let handle = self.handle - return try await Task.detached(priority: .userInitiated) { () -> Data in + return try await Self.onRotationQueue { () -> Data in let resolver = MnemonicResolver() var txidTuple = zeroTxidTuple() let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in @@ -179,7 +205,7 @@ extension PlatformWalletManager { throw PlatformWalletError(result: result) } return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } - }.value + } } /// Prepare-only sibling of `masternodeUpdateRegistrar` for the @@ -201,7 +227,7 @@ extension PlatformWalletManager { } try requireNoEmbeddedNul(payoutAddress, "payout address") let handle = self.handle - let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let transactionHandle = try await Self.onRotationQueue { () -> Handle in let resolver = MnemonicResolver() var outHandle: Handle = NULL_HANDLE let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in @@ -227,7 +253,7 @@ extension PlatformWalletManager { throw PlatformWalletError(result: result) } return outHandle - }.value + } return try FinalizedCoreTransaction(handle: transactionHandle) } @@ -251,7 +277,7 @@ extension PlatformWalletManager { try requireNoEmbeddedNul(ownerKey, "owner key") try requireNoEmbeddedNul(payoutAddress, "payout address") let handle = self.handle - return try await Task.detached(priority: .userInitiated) { () -> Data in + return try await Self.onRotationQueue { () -> Data in let resolver = MnemonicResolver() var txidTuple = zeroTxidTuple() let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in @@ -279,7 +305,7 @@ extension PlatformWalletManager { throw PlatformWalletError(result: result) } return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } - }.value + } } /// Prepare-only sibling of `trackedMasternodeUpdateRegistrar`. @@ -300,7 +326,7 @@ extension PlatformWalletManager { 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 transactionHandle = try await Self.onRotationQueue { () -> Handle in let resolver = MnemonicResolver() var outHandle: Handle = NULL_HANDLE let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in @@ -328,7 +354,7 @@ extension PlatformWalletManager { throw PlatformWalletError(result: result) } return outHandle - }.value + } return try FinalizedCoreTransaction(handle: transactionHandle) } @@ -363,7 +389,7 @@ extension PlatformWalletManager { try requireNoEmbeddedNul(operatorPayoutAddress, "operator payout address") } let handle = self.handle - return try await Task.detached(priority: .userInitiated) { () -> Data in + return try await Self.onRotationQueue { () -> Data in let resolver = MnemonicResolver() var txidTuple = zeroTxidTuple() let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in @@ -395,7 +421,7 @@ extension PlatformWalletManager { throw PlatformWalletError(result: result) } return Swift.withUnsafeBytes(of: &txidTuple) { Data($0) } - }.value + } } /// Prepare-only sibling of `masternodeUpdateServiceWithValues`. @@ -421,7 +447,7 @@ extension PlatformWalletManager { try requireNoEmbeddedNul(operatorPayoutAddress, "operator payout address") } let handle = self.handle - let transactionHandle = try await Task.detached(priority: .userInitiated) { () -> Handle in + let transactionHandle = try await Self.onRotationQueue { () -> Handle in let resolver = MnemonicResolver() var outHandle: Handle = NULL_HANDLE let ffiResult = withExtendedLifetime(resolver) { () -> PlatformWalletFFIResult in @@ -453,7 +479,7 @@ extension PlatformWalletManager { throw PlatformWalletError(result: result) } return outHandle - }.value + } return try FinalizedCoreTransaction(handle: transactionHandle) }