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..ef66c16d9c4 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,100 @@ 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 { + // 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); + *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) + }); + 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 f53b0df033c..e92b5366444 100644 --- a/packages/rs-platform-wallet-ffi/src/types.rs +++ b/packages/rs-platform-wallet-ffi/src/types.rs @@ -124,17 +124,32 @@ impl IdentifierArray { } pub fn new(identifiers: Vec) -> Self { - let count = identifiers.len(); + 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. 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: Vec<[u8; 32]> = identifiers.into_iter().map(|id| id.to_buffer()).collect(); + let items = Box::into_raw(hashes.into_boxed_slice()) as *mut [u8; 32]; - let ptr = items.as_mut_ptr(); - std::mem::forget(items); - - Self { items: ptr, count } + Self { items, count } } } @@ -151,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(); @@ -215,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 { diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 2dae6fa2ebe..74eb9be3b21 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -7,7 +7,8 @@ use tokio::sync::RwLock; use tokio::task::JoinHandle; use dashcore::sml::llmq_type::LLMQType; -use dashcore::{QuorumHash, Transaction}; +use dashcore::sml::masternode_list::MasternodeList; +use dashcore::{PubkeyHash, QuorumHash, Transaction}; use dash_spv::network::PeerNetworkManager; use dash_spv::storage::{DiskStorageManager, StorageManager}; @@ -349,6 +350,46 @@ 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(); + }; + + masternodes_by_voting_key(list, voting_key_id) + } + /// Get the current sync progress. /// /// Returns `None` if the SPV client is not running. @@ -433,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::*; 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()`).