From 69417659ce5594a4a6d1f8816dc38c76666095b4 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:04:52 -0400 Subject: [PATCH 1/4] feat(sdk): masternodes-by-voting-key lookup surfaced to Kotlin SPV-runtime accessor (current-tip MasternodeList via latest_masternode_list) -> platform-wallet-ffi platform_wallet_manager_masternodes_by_voting_key (IdentifierArray of proTxHashes) -> JNI masternodesByVotingKey (flat 32-byte rows, internal order) -> Kotlin external fun. Replaces the app's dashj getMasternodesByVotingKey dependency for contested-username voting post-cutover. Uses the new rust-dashcore MasternodeList::masternodes_by_voting_key helper (rev 4d927c15). Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/ffi/WalletManagerNative.kt | 19 ++++++ packages/rs-platform-wallet-ffi/src/spv.rs | 40 +++++++++++- packages/rs-platform-wallet-ffi/src/types.rs | 17 ++++++ .../rs-platform-wallet/src/spv/runtime.rs | 49 ++++++++++++++- .../rs-unified-sdk-jni/src/wallet_manager.rs | 61 +++++++++++++++++++ 5 files changed, 184 insertions(+), 2 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 0dfbbedc89d..70b20768906 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -496,6 +496,25 @@ internal object WalletManagerNative { external fun spvIsRunning(managerHandle: Long): Boolean external fun spvStop(managerHandle: Long) + /** + * The proTxHashes of every masternode in the current-tip deterministic + * masternode list whose voting-key hash matches the 20-byte [votingKeyId] + * (hash160 of a voting public key), as a flat `byte[]` of concatenated + * 32-byte proTxHashes (internal byte order) — the caller splits into + * 32-byte rows. Replaces dashj's + * `MasternodeListManager.getMasternodesByVotingKey(votingKeyId)` used by + * contested-username voting. Returns an EMPTY (non-null) `byte[]` when the + * masternode list hasn't synced (SPV client not running / DML unavailable) + * or no masternode uses the key; throws only on a structural FFI error. + * JNI symbol: + * `Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_masternodesByVotingKey`; + * bridges `platform_wallet_manager_masternodes_by_voting_key`. + */ + external fun masternodesByVotingKey( + managerHandle: Long, + votingKeyId: ByteArray, + ): ByteArray + // ── Wallet-memory snapshots (Wave-1B) ───────────────────────────── /** diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 3a20386628a..31e8ca9946b 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -3,7 +3,9 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use dashcore::hashes::Hash; use dashcore::sml::llmq_type::LlmqDevnetParams; +use dashcore::PubkeyHash; use platform_wallet::spv::{ ClientConfig, DevnetConfig, ProgressPercentage, SpvPeerNodeType, SyncProgress, SyncState, }; @@ -11,7 +13,7 @@ use platform_wallet::spv::{ use crate::error::*; use crate::handle::*; use crate::runtime::{block_on_worker, runtime}; -use crate::types::FFINetwork; +use crate::types::{FFINetwork, IdentifierArray}; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; pub const SPV_SYNC_STATE_WAIT_FOR_EVENTS: u32 = 0; @@ -152,6 +154,42 @@ pub unsafe extern "C" fn platform_wallet_manager_sync_progress( PlatformWalletFFIResult::ok() } +/// The proTxHashes of every masternode in the current-tip deterministic +/// masternode list whose voting key hash matches the 20-byte `voting_key_id`. +/// +/// Replaces dashj's `MasternodeListManager.getMasternodesByVotingKey(...)`, +/// the lookup contested-username voting uses. On success `*out_array` owns a +/// flat `[[u8; 32]]` of `count` proTxHashes (internal byte order), which the +/// caller must release via [`crate::platform_wallet_identifier_array_free`]. +/// An unsynced/stopped client (or a voting key no masternode uses) returns +/// `ok()` with the empty `(null, 0)` sentinel. +/// +/// # Safety +/// - `voting_key_id` must point at 20 readable bytes. +/// - `out_array` must be a valid `*mut IdentifierArray`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_masternodes_by_voting_key( + handle: Handle, + voting_key_id: *const u8, + out_array: *mut IdentifierArray, +) -> PlatformWalletFFIResult { + check_ptr!(voting_key_id); + check_ptr!(out_array); + // Publish the empty sentinel before any fallible work so an error return + // never leaves the out-param holding uninitialized stack bytes. + *out_array = IdentifierArray::empty(); + + let key_bytes: [u8; 20] = std::ptr::read(voting_key_id as *const [u8; 20]); + let voting_key = PubkeyHash::from_byte_array(key_bytes); + + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + manager.spv().masternodes_by_voting_key_blocking(&voting_key) + }); + let hashes = unwrap_option_or_return!(option); + *out_array = IdentifierArray::from_hashes(hashes); + PlatformWalletFFIResult::ok() +} + pub const SPV_PEER_NODE_TYPE_UNKNOWN: u32 = 0; pub const SPV_PEER_NODE_TYPE_NORMAL: u32 = 1; pub const SPV_PEER_NODE_TYPE_MASTERNODE: u32 = 2; diff --git a/packages/rs-platform-wallet-ffi/src/types.rs b/packages/rs-platform-wallet-ffi/src/types.rs index f53b0df033c..1d99c323dcf 100644 --- a/packages/rs-platform-wallet-ffi/src/types.rs +++ b/packages/rs-platform-wallet-ffi/src/types.rs @@ -136,6 +136,23 @@ impl IdentifierArray { Self { items: ptr, count } } + + /// Build the array from raw 32-byte rows (e.g. proTxHashes), which are not + /// platform `Identifier`s. Same ownership contract as [`Self::new`]: the + /// buffer is heap-leaked here and reclaimed by + /// [`platform_wallet_identifier_array_free`]. + pub fn from_hashes(hashes: Vec<[u8; 32]>) -> Self { + let count = hashes.len(); + if count == 0 { + return Self::empty(); + } + + let mut items = hashes; + let ptr = items.as_mut_ptr(); + std::mem::forget(items); + + Self { items: ptr, count } + } } /// Free identifier array. diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 2dae6fa2ebe..1cf6d8eb04b 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -7,7 +7,7 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use dashcore::sml::llmq_type::LLMQType; -use dashcore::{QuorumHash, Transaction}; +use dashcore::{PubkeyHash, QuorumHash, Transaction}; use dash_spv::network::PeerNetworkManager; use dash_spv::storage::{DiskStorageManager, StorageManager}; @@ -349,6 +349,53 @@ impl SpvRuntime { Some(map) } + /// The proTxHashes of every masternode in the current-tip deterministic + /// masternode list whose voting key hash matches `voting_key_id` (the + /// 20-byte hash160 of a voting public key). + /// + /// Replaces dashj's + /// `MasternodeListManager.getMasternodesByVotingKey(votingKeyId)`, the + /// lookup contested-username voting uses to find which masternode(s) a + /// voting key can cast a vote for. The current tip is the highest + /// `CoreBlockHeight` held by the engine (`latest_masternode_list`). + /// + /// Each proTxHash is returned in internal byte order — the same + /// `pro_reg_tx_hash.as_ref()` convention as + /// [`Self::masternode_validity_snapshot_blocking`]. Returns an empty vec + /// when the DML isn't available (SPV client not running, engine not + /// initialized, or the masternode list hasn't synced yet). Blocking: + /// acquires the client + engine `tokio::RwLock`s via `blocking_read`, so + /// it must run off the async runtime (FFI blocking thread), mirroring the + /// other `*_blocking` accessors. + pub fn masternodes_by_voting_key_blocking(&self, voting_key_id: &PubkeyHash) -> Vec<[u8; 32]> { + // Clone the engine `Arc` out while holding the client lock, then drop + // it before reading the engine — same ordering as `connected_peers`. + let engine = { + let client_guard = self.client.blocking_read(); + let Some(client) = client_guard.as_ref() else { + return Vec::new(); + }; + match client.masternode_list_engine().ok() { + Some(engine) => engine, + None => return Vec::new(), + } + }; + + let engine_guard = engine.blocking_read(); + let Some(list) = engine_guard.latest_masternode_list() else { + return Vec::new(); + }; + + list.masternodes_by_voting_key(voting_key_id) + .into_iter() + .map(|pro_tx| { + let mut out = [0u8; 32]; + out.copy_from_slice(pro_tx.as_ref()); + out + }) + .collect() + } + /// Get the current sync progress. /// /// Returns `None` if the SPV client is not running. diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 8e0f3e676b5..3221423daf5 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -2806,6 +2806,30 @@ fn read_id32(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 32]> { Some(id) } +/// Read a required 20-byte `byte[]` (e.g. a voting-key hash160) into `[u8; 20]`; +/// throws + returns None on a null/invalid array or a wrong length. +fn read_id20(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 20]> { + let bytes = match env.convert_byte_array(arr) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "votingKeyId byte[] was null/invalid"); + return None; + } + }; + if bytes.len() != 20 { + throw_sdk_exception( + env, + 1, + &format!("votingKeyId must be 20 bytes, got {}", bytes.len()), + ); + return None; + } + let mut id = [0u8; 20]; + id.copy_from_slice(&bytes); + Some(id) +} + /// Read a required Java `String` into an owned `CString`; throws + returns /// None on JVM null, a JNI error, an empty string, or an interior NUL. fn read_cstring_required(env: &mut JNIEnv, s: &JString, field: &str) -> Option { @@ -3134,6 +3158,43 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }) } +/// The proTxHashes of every masternode whose voting key hash matches the +/// 20-byte `votingKeyId`, as a flat `byte[]` (concatenated 32-byte +/// proTxHashes; Kotlin splits into 32-byte rows). Replaces dashj's +/// `MasternodeListManager.getMasternodesByVotingKey(votingKeyId)` used by +/// contested-username voting. Returns an empty `byte[]` when the masternode +/// list hasn't synced (SPV client not running / DML unavailable) or no +/// masternode uses the key. Bridges +/// `platform_wallet_manager_masternodes_by_voting_key`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_masternodesByVotingKey( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + voting_key_id: JByteArray, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(key) = read_id20(env, &voting_key_id) else { + return ptr::null_mut(); + }; + let mut out = IdentifierArray { + items: ptr::null_mut(), + count: 0, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_masternodes_by_voting_key( + manager_handle as Handle, + key.as_ptr(), + &mut out as *mut IdentifierArray, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + identifier_array_to_flat(env, out) + }) +} + /// The BIP-9 identity index recorded on a managed-identity snapshot handle, /// or `-1` when the identity is out-of-wallet (no index). Bridges /// `managed_identity_get_identity_index` (Swift `mi.getIdentityIndex()`). From 5adfc40032532114449607213a2a11b8b8d887e9 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:47:53 -0400 Subject: [PATCH 2/4] chore(deps): pin rust-dashcore to the fork rev carrying masternodes_by_voting_key `MasternodeList::masternodes_by_voting_key` (with its unit test) is not yet in dashpay/rust-dashcore, so the 8 workspace git deps move from dashpay/rust-dashcore@70d4bf8e to bfoss765/rust-dashcore@4d927c15 for the lifetime of this PR. The fork rev is a strict fast-forward superset of the rev v4.2-dev already pins: 4 commits ahead, 0 behind, no divergence. Revert this commit once the helper lands upstream and re-pin to dashpay/rust-dashcore. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 066dfe24847..d9f70630b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "bincode", "bincode_derive", @@ -1671,7 +1671,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "dash-network", ] @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "async-trait", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "anyhow", "base64-compat", @@ -1803,12 +1803,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "dashcore-rpc-json", "hex", @@ -1821,7 +1821,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "bincode", "dashcore", @@ -1836,7 +1836,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "bincode", "dashcore-private", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" [[package]] name = "glob" @@ -4093,7 +4093,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "aes", "async-trait", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" +source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 86e5432b7ef..e7937ced303 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dashcore = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +dash-network-seeds = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +dash-spv = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +key-wallet = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +key-wallet-ffi = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +key-wallet-manager = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +dash-network = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +dashcore-rpc = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } tokio-metrics = "0.5" From b1abb827e38ae0ae38cbfac22da177216168943c Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:01:32 -0400 Subject: [PATCH 3/4] fix(ffi): free identifier arrays with the layout they were allocated with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two blockers from the #4258 review. (a) `IdentifierArray::from_hashes` leaked a `Vec<[u8; 32]>` but exported only `(ptr, len)`, while `platform_wallet_identifier_array_free` reconstructed it as `Vec::from_raw_parts(items, count, count)` — valid only when the original capacity happened to equal `count`. On the production path it does not: `masternodes_by_voting_key` collects through a `filter`, whose `size_hint` lower bound is 0, so an ordinary one-match lookup yields len 1 / capacity 4. Measured with a layout-checking allocator, that allocates 128 bytes and frees as 32 — undefined behaviour, and JNI invokes the free unconditionally. Both constructors now hand over an exact-length boxed slice (`into_boxed_slice`), and the free path reclaims it as the same `Box<[[u8; 32]]>`, so creation and release use identical layouts regardless of what capacity the producer had. `new` delegates to `from_hashes` so there is a single ownership contract rather than two. (b) `check_ptr!(voting_key_id)` returned before the out-param sentinel was published, so a null input pointer left `*out_array` holding the caller's uninitialized stack — breaking the documented initialized-out contract and handing a cleanup-on-error C/Swift caller an arbitrary pointer to free. The `out_array` guard and sentinel are hoisted above every other guard. Tests: a filtered-collect round trip pinning the capacity != len precondition, a multi-row byte-for-byte round trip, the empty sentinel, and both null-pointer paths. The sentinel test was confirmed to fail against the old guard order. 209 lib tests pass; also fixes a pre-existing rustfmt violation in this function that had CI's format check red. The Cargo.toml fork pin (blocker 3) is deliberately untouched. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/spv.rs | 66 +++++++++++- packages/rs-platform-wallet-ffi/src/types.rs | 104 +++++++++++++++---- 2 files changed, 148 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/spv.rs b/packages/rs-platform-wallet-ffi/src/spv.rs index 31e8ca9946b..ef66c16d9c4 100644 --- a/packages/rs-platform-wallet-ffi/src/spv.rs +++ b/packages/rs-platform-wallet-ffi/src/spv.rs @@ -173,23 +173,81 @@ pub unsafe extern "C" fn platform_wallet_manager_masternodes_by_voting_key( voting_key_id: *const u8, out_array: *mut IdentifierArray, ) -> PlatformWalletFFIResult { - check_ptr!(voting_key_id); + // Validate and publish the sentinel BEFORE any other guard. `check_ptr!` + // returns early, so validating `voting_key_id` first meant a null input + // pointer returned `ErrorNullPointer` with `*out_array` still holding + // whatever the caller's stack had — breaking this function's documented + // promise that the out-param is initialized on every path. A C or Swift + // caller that frees unconditionally would then hand + // `platform_wallet_identifier_array_free` an arbitrary pointer + // (dashpay/platform#4258 review). check_ptr!(out_array); - // Publish the empty sentinel before any fallible work so an error return - // never leaves the out-param holding uninitialized stack bytes. *out_array = IdentifierArray::empty(); + check_ptr!(voting_key_id); let key_bytes: [u8; 20] = std::ptr::read(voting_key_id as *const [u8; 20]); let voting_key = PubkeyHash::from_byte_array(key_bytes); let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { - manager.spv().masternodes_by_voting_key_blocking(&voting_key) + manager + .spv() + .masternodes_by_voting_key_blocking(&voting_key) }); let hashes = unwrap_option_or_return!(option); *out_array = IdentifierArray::from_hashes(hashes); PlatformWalletFFIResult::ok() } +#[cfg(test)] +mod masternodes_by_voting_key_tests { + use super::*; + use crate::error::platform_wallet_ffi_result_free; + + /// The function documents that `*out_array` is initialized on EVERY path. + /// A null `voting_key_id` must therefore still leave the sentinel behind: + /// a C or Swift caller that frees unconditionally on error would otherwise + /// hand `platform_wallet_identifier_array_free` whatever its uninitialized + /// stack slot happened to hold (dashpay/platform#4258 review). + #[test] + fn null_input_pointer_still_initializes_the_out_param() { + // Pre-poison the out-param the way an uninitialized C local looks: + // non-null pointer, non-zero count. If the guard order regresses, this + // survives the call and a cleanup-on-error caller frees it. + let mut out = IdentifierArray { + items: 0xdead_beef_usize as *mut [u8; 32], + count: 9, + }; + + let mut result = unsafe { + platform_wallet_manager_masternodes_by_voting_key(0, std::ptr::null(), &mut out) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert!( + out.items.is_null() && out.count == 0, + "the empty sentinel must be published before the input-pointer \ + guard returns (got items={:?}, count={})", + out.items, + out.count, + ); + + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } + + /// A null `out_array` has nowhere to publish the sentinel, so it must be + /// rejected — and must not be dereferenced on the way out. + #[test] + fn null_out_param_is_rejected() { + let mut result = unsafe { + let key = [0u8; 20]; + platform_wallet_manager_masternodes_by_voting_key(0, key.as_ptr(), std::ptr::null_mut()) + }; + + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + } +} + pub const SPV_PEER_NODE_TYPE_UNKNOWN: u32 = 0; pub const SPV_PEER_NODE_TYPE_NORMAL: u32 = 1; pub const SPV_PEER_NODE_TYPE_MASTERNODE: u32 = 2; diff --git a/packages/rs-platform-wallet-ffi/src/types.rs b/packages/rs-platform-wallet-ffi/src/types.rs index 1d99c323dcf..e92b5366444 100644 --- a/packages/rs-platform-wallet-ffi/src/types.rs +++ b/packages/rs-platform-wallet-ffi/src/types.rs @@ -124,34 +124,32 @@ impl IdentifierArray { } pub fn new(identifiers: Vec) -> Self { - let count = identifiers.len(); - if count == 0 { - return Self::empty(); - } - - let mut items: Vec<[u8; 32]> = identifiers.into_iter().map(|id| id.to_buffer()).collect(); - - let ptr = items.as_mut_ptr(); - std::mem::forget(items); - - Self { items: ptr, count } + Self::from_hashes(identifiers.into_iter().map(|id| id.to_buffer()).collect()) } /// Build the array from raw 32-byte rows (e.g. proTxHashes), which are not - /// platform `Identifier`s. Same ownership contract as [`Self::new`]: the - /// buffer is heap-leaked here and reclaimed by + /// platform `Identifier`s. The buffer is heap-leaked here and reclaimed by /// [`platform_wallet_identifier_array_free`]. + /// + /// The rows are moved into an exact-length **boxed slice** before ownership + /// is transferred, so the allocation is exactly `count` elements wide and + /// the free path can reconstruct it with the identical layout. Leaking the + /// `Vec` directly would export only `(ptr, len)` while the allocation kept + /// whatever spare capacity the producer happened to have — and freeing an + /// allocation with a capacity it was not created with is undefined + /// behaviour. That is not hypothetical: `masternodes_by_voting_key` + /// collects through a `filter`, whose `size_hint` lower bound is 0, so an + /// ordinary one-match lookup routinely produces `capacity != len` + /// (dashpay/platform#4258 review). pub fn from_hashes(hashes: Vec<[u8; 32]>) -> Self { let count = hashes.len(); if count == 0 { return Self::empty(); } - let mut items = hashes; - let ptr = items.as_mut_ptr(); - std::mem::forget(items); + let items = Box::into_raw(hashes.into_boxed_slice()) as *mut [u8; 32]; - Self { items: ptr, count } + Self { items, count } } } @@ -168,8 +166,12 @@ pub unsafe extern "C" fn platform_wallet_identifier_array_free(array: *mut Ident } let array = unsafe { &mut *array }; if !array.items.is_null() && array.count > 0 { + // Mirror image of [`IdentifierArray::from_hashes`]: the allocation was + // handed over as an exact-length boxed slice, so reclaim it as one. + // (The previous `Vec::from_raw_parts(items, count, count)` asserted a + // capacity the producer never guaranteed — see that constructor.) unsafe { - let _ = Vec::from_raw_parts(array.items, array.count, array.count); + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(array.items, array.count)); } } array.items = std::ptr::null_mut(); @@ -232,6 +234,72 @@ mod tests { } } + /// The production `masternodes_by_voting_key` path collects through a + /// `filter`, so its `Vec` routinely carries spare capacity — a one-match + /// lookup over many masternodes is the common case. Creation and free must + /// agree on the allocation layout regardless; under Miri (or a + /// capacity-checking allocator) the old `Vec::from_raw_parts(p, n, n)` + /// free of such a buffer is undefined behaviour + /// (dashpay/platform#4258 review). + #[test] + fn identifier_array_frees_rows_collected_with_spare_capacity() { + unsafe { + // Exactly the production shape: filter many rows down to one. + let hashes: Vec<[u8; 32]> = (0u8..64) + .map(|i| [i; 32]) + .filter(|row| row[0] == 7) + .collect(); + assert_eq!(hashes.len(), 1); + assert!( + hashes.capacity() > hashes.len(), + "precondition: a filtered collect must over-allocate for this \ + test to exercise the layout mismatch (len {}, capacity {})", + hashes.len(), + hashes.capacity(), + ); + + let mut array = IdentifierArray::from_hashes(hashes); + assert_eq!(array.count, 1); + assert!(!array.items.is_null()); + assert_eq!(*array.items, [7u8; 32], "the row must survive intact"); + + platform_wallet_identifier_array_free(&mut array); + assert!(array.items.is_null()); + assert_eq!(array.count, 0); + } + } + + /// Multi-row round trip: every row must come back byte-for-byte in order, + /// and the whole buffer must free cleanly. + #[test] + fn identifier_array_from_hashes_round_trips_every_row() { + unsafe { + let rows: Vec<[u8; 32]> = (0u8..5).map(|i| [i; 32]).collect(); + let mut array = IdentifierArray::from_hashes(rows.clone()); + assert_eq!(array.count, rows.len()); + + let seen = std::slice::from_raw_parts(array.items, array.count); + assert_eq!(seen, rows.as_slice()); + + platform_wallet_identifier_array_free(&mut array); + assert!(array.items.is_null()); + } + } + + /// `from_hashes` with no rows must produce the `(null, 0)` sentinel, which + /// the free path skips — an empty lookup result is not an allocation. + #[test] + fn identifier_array_from_hashes_empty_is_the_sentinel() { + unsafe { + let mut array = IdentifierArray::from_hashes(Vec::new()); + assert!(array.items.is_null()); + assert_eq!(array.count, 0); + // Freeing the sentinel is a no-op, and idempotent. + platform_wallet_identifier_array_free(&mut array); + platform_wallet_identifier_array_free(&mut array); + } + } + #[test] fn test_read_identifier_round_trip() { unsafe { From ce8233edb7b100f163d2c3e0746f325f8624d68f Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:41:42 -0400 Subject: [PATCH 4/4] fix(deps): drop the rust-dashcore fork pin for a local voting-key filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All eight workspace rust-dashcore dependencies were redirected at bfoss765/rust-dashcore solely to pick up `MasternodeList::masternodes_by_voting_key`. That revision also carried ~528 lines of unrelated owner-tagged-reservation and asset-lock behaviour, and the upstream PR carrying the helper (dashpay/rust-dashcore#916) is CHANGES_REQUESTED, conflicting against dev, and has been asked to split — so the pin had no bounded lifetime. Revert the pin to the Dash-owned revision v4.2-dev already tracks (70d4bf8e36057c58e02d56769a6e9760f701dd06) and implement the filter locally in `spv/runtime.rs` as a private free function over the engine's current-tip MasternodeList. It reads only long-standing public SML fields (`key_id_voting`, `pro_reg_tx_hash`, both present on that revision), and folds in the `ProTxHash -> [u8; 32]` internal-byte-order conversion the caller previously did itself, so `masternodes_by_voting_key_blocking` gets shorter rather than longer. Four unit tests cover multi-match, single-match, no-match and the empty-list case. Swapping back to the upstream helper once #916 lands is tracked by #4262, cited in a doc comment at the duplication site. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 24 +-- Cargo.toml | 16 +- .../rs-platform-wallet/src/spv/runtime.rs | 138 +++++++++++++++++- 3 files changed, 150 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9f70630b93..066dfe24847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1660,7 +1660,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "bincode", "bincode_derive", @@ -1671,7 +1671,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "dash-network", ] @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "async-trait", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "anyhow", "base64-compat", @@ -1803,12 +1803,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "dashcore-rpc-json", "hex", @@ -1821,7 +1821,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "bincode", "dashcore", @@ -1836,7 +1836,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "bincode", "dashcore-private", @@ -2902,7 +2902,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" [[package]] name = "glob" @@ -4093,7 +4093,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "aes", "async-trait", @@ -4122,7 +4122,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/bfoss765/rust-dashcore?rev=4d927c155b6740ba7e1f271144217f7e82b69990#4d927c155b6740ba7e1f271144217f7e82b69990" +source = "git+https://github.com/dashpay/rust-dashcore?rev=70d4bf8e36057c58e02d56769a6e9760f701dd06#70d4bf8e36057c58e02d56769a6e9760f701dd06" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index e7937ced303..86e5432b7ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -dash-network-seeds = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -dash-spv = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -key-wallet = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -key-wallet-ffi = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -key-wallet-manager = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -dash-network = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } -dashcore-rpc = { git = "https://github.com/bfoss765/rust-dashcore", rev = "4d927c155b6740ba7e1f271144217f7e82b69990" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "70d4bf8e36057c58e02d56769a6e9760f701dd06" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 1cf6d8eb04b..74eb9be3b21 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -7,6 +7,7 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use dashcore::sml::llmq_type::LLMQType; +use dashcore::sml::masternode_list::MasternodeList; use dashcore::{PubkeyHash, QuorumHash, Transaction}; use dash_spv::network::PeerNetworkManager; @@ -386,14 +387,7 @@ impl SpvRuntime { return Vec::new(); }; - list.masternodes_by_voting_key(voting_key_id) - .into_iter() - .map(|pro_tx| { - let mut out = [0u8; 32]; - out.copy_from_slice(pro_tx.as_ref()); - out - }) - .collect() + masternodes_by_voting_key(list, voting_key_id) } /// Get the current sync progress. @@ -480,6 +474,134 @@ impl SpvRuntime { } } +/// The proTxHashes (internal byte order) of every entry in `list` whose +/// `key_id_voting` equals `voting_key_id`. +/// +/// A single voting key can back more than one masternode, so this is a +/// filter-and-collect rather than a point lookup; the result is empty when +/// nothing matches. +/// +/// # Why this lives here instead of in rust-dashcore +/// +/// This duplicates `MasternodeList::masternodes_by_voting_key`, which is not +/// present on the Dash-owned rust-dashcore revision this workspace pins. The +/// upstream helper is still in flight as dashpay/rust-dashcore#916 and that PR +/// is blocked on being split, so pinning to a revision carrying it would mean +/// depending on a personal fork for an indefinite period. The filter is small +/// and reads only long-standing public SML fields, so keeping a local copy is +/// cheaper than the fork pin. +/// +/// Delete this function and call `list.masternodes_by_voting_key(voting_key_id)` +/// once #916 lands and the workspace pin moves past it — tracked by +/// dashpay/platform#4262. +fn masternodes_by_voting_key(list: &MasternodeList, voting_key_id: &PubkeyHash) -> Vec<[u8; 32]> { + list.masternodes + .values() + .filter(|qualified| qualified.masternode_list_entry.key_id_voting == *voting_key_id) + .map(|qualified| { + // Internal byte order, matching `masternode_validity_snapshot_blocking`: + // the DML map keys by the reversed/display form, so read the hash off + // the entry rather than the map key. + let mut out = [0u8; 32]; + out.copy_from_slice(qualified.masternode_list_entry.pro_reg_tx_hash.as_ref()); + out + }) + .collect() +} + +#[cfg(test)] +mod masternodes_by_voting_key_tests { + use dashcore::bls_sig_utils::BLSPublicKey; + use dashcore::hashes::Hash; + use dashcore::sml::masternode_list_entry::{ + EntryMasternodeType, MasternodeListEntry, MasternodeNetInfo, + }; + use dashcore::{BlockHash, ProTxHash, PubkeyHash}; + use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; + + use super::{masternodes_by_voting_key, MasternodeList}; + + /// Build a list from `(proTxHash-seed, voting-key-id)` pairs so each entry + /// gets a distinct proTxHash and a caller-chosen voting key. + fn list_from(entries: Vec<(u8, [u8; 20])>) -> MasternodeList { + let masternodes = entries + .into_iter() + .map(|(seed, voting_key_id)| { + let mut hash_bytes = [0u8; 32]; + hash_bytes[0] = seed; + let pro_tx_hash = ProTxHash::from_byte_array(hash_bytes); + let entry = MasternodeListEntry { + version: 1, + pro_reg_tx_hash: pro_tx_hash, + confirmed_hash: None, + service_address: MasternodeNetInfo::Legacy(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(10, 0, 0, seed), + 9999, + ))), + operator_public_key: BLSPublicKey::from([0u8; 48]), + key_id_voting: PubkeyHash::from_byte_array(voting_key_id), + is_valid: true, + mn_type: EntryMasternodeType::Regular, + }; + (pro_tx_hash, entry.into()) + }) + .collect(); + MasternodeList::build( + masternodes, + Default::default(), + BlockHash::from_byte_array([0u8; 32]), + 0, + ) + .build() + } + + #[test] + fn collects_every_masternode_sharing_a_voting_key() { + let key_a = [0xAAu8; 20]; + let key_b = [0xBBu8; 20]; + // Two masternodes share voting key A, one uses key B. + let list = list_from(vec![(1, key_a), (2, key_b), (3, key_a)]); + + let mut matched = masternodes_by_voting_key(&list, &PubkeyHash::from_byte_array(key_a)); + // Iteration is BTreeMap (proTxHash) order; sort on the seed byte so the + // assert does not depend on it. + matched.sort_by_key(|hash| hash[0]); + assert_eq!(matched.len(), 2, "both key-A masternodes must be returned"); + assert_eq!(matched[0][0], 1); + assert_eq!(matched[1][0], 3); + } + + #[test] + fn returns_the_single_masternode_for_an_unshared_voting_key() { + let key_a = [0xAAu8; 20]; + let key_b = [0xBBu8; 20]; + let list = list_from(vec![(1, key_a), (2, key_b), (3, key_a)]); + + let matched = masternodes_by_voting_key(&list, &PubkeyHash::from_byte_array(key_b)); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0][0], 2); + } + + #[test] + fn returns_empty_when_no_masternode_uses_the_voting_key() { + let list = list_from(vec![(1, [0xAAu8; 20]), (2, [0xBBu8; 20])]); + + let matched = masternodes_by_voting_key(&list, &PubkeyHash::from_byte_array([0xCCu8; 20])); + assert!( + matched.is_empty(), + "an unused voting key must match nothing" + ); + } + + #[test] + fn returns_empty_for_an_empty_masternode_list() { + let list = list_from(vec![]); + + let matched = masternodes_by_voting_key(&list, &PubkeyHash::from_byte_array([0xAAu8; 20])); + assert!(matched.is_empty()); + } +} + #[cfg(test)] mod shutdown_tests { use super::*;