From b99c0e96222a418ed3339679cca7b5ec8f676ac4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 13 Jun 2026 15:14:07 +0200 Subject: [PATCH 1/4] feat(sdk-ffi): add masternode contested-resource vote broadcast (FFI + Swift UI) Adds the missing write path for casting a contested-resource (DPNS masternode) vote. Previously rs-sdk-ffi exposed only contested-resource *query* functions, so the app could read vote tallies but could not cast a vote. Rust FFI (rs-sdk-ffi): - New `dash_sdk_contested_resource_cast_vote` under `contested_resource/transitions/cast_vote.rs`. It assembles a `Vote::ResourceVote` over the contested-document vote poll (contract_id, document_type_name, index_name, index_values) with a `ResourceVoteChoice` (TowardsIdentity / Abstain / Lock) and broadcasts it via the existing rs-sdk `PutVote::put_to_platform_and_wait_for_response`. - The masternode voting key is derived from the supplied 32-byte voting private key: a `SingleKeySigner` signs, and the matching ECDSA_HASH160 / VOTING / HIGH `IdentityPublicKey` (data = hash160 of the public key) is built to mirror Platform's `get_voter_identity_key_v0`. `SingleKeySigner::can_sign_with` recomputes the same hash160, so the key and signer agree by construction. The voter identity is derived from pro_tx_hash + voting key by the transition itself. - Index-value parsing matches the vote-state query FFI (hex -> Bytes, otherwise Text) so a poll readable via get_vote_state is the same poll voted on here. Swift SDK + UI: - `SDK.castContestedResourceVote(...)` thin bridge in PlatformQueryExtensions, plus a `ContestedResourceVoteChoice` enum. - ContestDetailView gains a "Cast a Masternode Vote" section + sheet to pick a contender / Abstain / Lock and enter masternode credentials. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rs-sdk-ffi/src/contested_resource/mod.rs | 2 + .../transitions/cast_vote.rs | 390 ++++++++++++++++++ .../src/contested_resource/transitions/mod.rs | 5 + .../FFI/PlatformQueryExtensions.swift | 92 +++++ .../PlatformWallet/ContestVoteState.swift | 24 ++ .../Views/ContestDetailView.swift | 236 +++++++++++ 6 files changed, 749 insertions(+) create mode 100644 packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs create mode 100644 packages/rs-sdk-ffi/src/contested_resource/transitions/mod.rs diff --git a/packages/rs-sdk-ffi/src/contested_resource/mod.rs b/packages/rs-sdk-ffi/src/contested_resource/mod.rs index b1ba67a5042..cb6cab2ff19 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/mod.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/mod.rs @@ -1,5 +1,7 @@ // Contested resource modules mod queries; +mod transitions; // Re-export all public functions pub use queries::*; +pub use transitions::*; diff --git a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs new file mode 100644 index 00000000000..04363f65174 --- /dev/null +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs @@ -0,0 +1,390 @@ +//! Masternode contested-resource vote broadcast. +//! +//! This is the *write* counterpart to the contested-resource query FFIs in +//! `crate::contested_resource::queries`. It assembles a +//! [`MasternodeVoteTransition`] from a contested-document vote poll +//! (`contract_id`, `document_type_name`, `index_name`, `index_values`) plus a +//! [`ResourceVoteChoice`] and broadcasts it via the existing rs-sdk +//! [`PutVote`] entry point. Nothing is stitched together here that the SDK +//! does not already expose as a single call. +//! +//! # Who can actually cast a vote +//! +//! A contested-resource (DPNS) vote is cast by a **masternode** using its +//! masternode *voting key* — an `ECDSA_HASH160` key whose 20-byte `data` is +//! the hash160 of the voting public key, tied to the masternode's +//! `pro_tx_hash` (see `get_voter_identity_key_v0` in rs-drive-abci). The +//! voter identity is derived deterministically as +//! `create_voter_identifier(pro_tx_hash, voting_address)`. +//! +//! This FFI therefore takes the raw 32-byte **voting private key** plus the +//! 32-byte `pro_tx_hash`. From the private key it derives: +//! * a [`SingleKeySigner`] that signs the transition, and +//! * the matching `ECDSA_HASH160` [`IdentityPublicKey`] (the masternode +//! voting key) whose `data` is `hash160(pubkey)`. +//! +//! The `SingleKeySigner::can_sign_with` check for `ECDSA_HASH160` recomputes +//! `hash160(pubkey)` from the same private key, so the derived key and signer +//! always agree. +//! +//! A regular wallet is **not** a masternode and has no voting key, so a vote +//! broadcast from such a wallet reaches a deterministic *authorization* +//! rejection on the platform side (the voter identity / masternode is not +//! found), which is the expected end state when testing without real +//! masternode credentials. The construction + signing + broadcast path is +//! fully exercised regardless. + +use crate::sdk::SDKWrapper; +use crate::types::{FFINetwork, Network, SDKHandle}; +use crate::{DashSDKResult, FFIError}; +use dash_sdk::dpp::dashcore::hashes::{hash160, Hash}; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::{BinaryData, Identifier, Value}; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dash_sdk::dpp::voting::vote_polls::VotePoll; +use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; +use dash_sdk::dpp::voting::votes::Vote; +use dash_sdk::platform::transition::vote::PutVote; +use simple_signer::SingleKeySigner; +use std::ffi::{c_char, CStr}; +use zeroize::Zeroizing; + +/// Discriminant for the resource vote choice, mirroring +/// `ResourceVoteChoice::try_from((i32, Option>))` in rs-dpp: +/// `0` = TowardsIdentity (requires `contender_identity_id`), `1` = Abstain, +/// `2` = Lock. +pub const VOTE_CHOICE_TOWARDS_IDENTITY: u8 = 0; +pub const VOTE_CHOICE_ABSTAIN: u8 = 1; +pub const VOTE_CHOICE_LOCK: u8 = 2; + +/// Cast a masternode contested-resource vote and wait for the response. +/// +/// Builds a `ResourceVote` over the contested-document vote poll +/// `(contract_id, document_type_name, index_name, index_values)` with the +/// given `vote_choice`, signs it with the masternode voting key derived from +/// `voting_private_key`, and broadcasts it for `voter_pro_tx_hash`. +/// +/// # Parameters +/// * `sdk_handle` — handle to an initialized SDK instance. +/// * `contract_id` — base58-encoded contested resource's data-contract id +/// (DPNS for username contests). +/// * `document_type_name` — contested document type (e.g. `"domain"`). +/// * `index_name` — contested index name (e.g. `"parentNameAndLabel"`). +/// * `index_values_json` — JSON array of index values. Each element is either +/// a hex-encoded byte string (decoded to `Value::Bytes`) or a plain string +/// (`Value::Text`), matching the parsing used by the vote-state query FFI. +/// * `vote_choice` — `0` = TowardsIdentity, `1` = Abstain, `2` = Lock. +/// * `contender_identity_id` — base58-encoded contender identity; required +/// (and only used) when `vote_choice == 0`, otherwise ignored / may be null. +/// * `voter_pro_tx_hash` — pointer to the masternode's 32-byte pro_tx_hash. +/// * `voting_private_key` — pointer to the 32-byte masternode voting private +/// key. Both the signer and the matching `ECDSA_HASH160` voting public key +/// are derived from this; the key never leaves this call as raw bytes. +/// * `network` — network used for WIF encoding inside `SingleKeySigner` +/// (does not affect signing or the derived key data). +/// +/// # Returns +/// `DashSDKResult` with no data on success, or an error. A vote from a +/// non-masternode wallet is expected to fail with an authorization-style +/// error from the platform — that is the correct deterministic outcome. +/// +/// # Safety +/// - `sdk_handle` must be a valid, non-null pointer to an initialized +/// `SDKHandle`. +/// - `contract_id`, `document_type_name`, `index_name`, `index_values_json` +/// must be valid NUL-terminated C strings for the duration of the call. +/// - `contender_identity_id` may be null unless `vote_choice == 0`. +/// - `voter_pro_tx_hash` and `voting_private_key` must each point to 32 +/// readable bytes for the duration of the call. +/// - All pointers must reference readable memory; passing dangling or +/// misaligned pointers is undefined behavior. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn dash_sdk_contested_resource_cast_vote( + sdk_handle: *const SDKHandle, + contract_id: *const c_char, + document_type_name: *const c_char, + index_name: *const c_char, + index_values_json: *const c_char, + vote_choice: u8, + contender_identity_id: *const c_char, + voter_pro_tx_hash: *const u8, + voting_private_key: *const u8, + network: FFINetwork, +) -> DashSDKResult { + match cast_vote_inner( + sdk_handle, + contract_id, + document_type_name, + index_name, + index_values_json, + vote_choice, + contender_identity_id, + voter_pro_tx_hash, + voting_private_key, + network, + ) { + Ok(()) => DashSDKResult::success(std::ptr::null_mut()), + Err(e) => DashSDKResult::error(e.into()), + } +} + +#[allow(clippy::too_many_arguments)] +unsafe fn cast_vote_inner( + sdk_handle: *const SDKHandle, + contract_id: *const c_char, + document_type_name: *const c_char, + index_name: *const c_char, + index_values_json: *const c_char, + vote_choice: u8, + contender_identity_id: *const c_char, + voter_pro_tx_hash: *const u8, + voting_private_key: *const u8, + network: FFINetwork, +) -> Result<(), FFIError> { + // ---- Null checks --------------------------------------------------------- + if sdk_handle.is_null() { + return Err(invalid("SDK handle is null")); + } + if contract_id.is_null() + || document_type_name.is_null() + || index_name.is_null() + || index_values_json.is_null() + { + return Err(invalid( + "contract_id, document_type_name, index_name and index_values_json must be non-null", + )); + } + if voter_pro_tx_hash.is_null() { + return Err(invalid("voter_pro_tx_hash is null")); + } + if voting_private_key.is_null() { + return Err(invalid("voting_private_key is null")); + } + + // ---- Parse the vote poll strings ---------------------------------------- + let contract_id_str = cstr(contract_id, "contract_id")?; + let document_type_name_str = cstr(document_type_name, "document_type_name")?; + let index_name_str = cstr(index_name, "index_name")?; + let index_values_str = cstr(index_values_json, "index_values_json")?; + + let contract_id_bytes = bs58::decode(contract_id_str) + .into_vec() + .map_err(|e| invalid(&format!("Failed to decode contract id: {}", e)))?; + let contract_id_arr: [u8; 32] = contract_id_bytes + .try_into() + .map_err(|_| invalid("contract id must be exactly 32 bytes"))?; + let contract_identifier = Identifier::new(contract_id_arr); + + // Same index-value parsing as the vote-state query FFI: hex → Bytes, + // otherwise Text. Keeping the two FFIs in lockstep means a poll that a + // caller can read with `get_vote_state` is the same poll it votes on here. + let index_values_array: Vec = serde_json::from_str(index_values_str) + .map_err(|e| invalid(&format!("Failed to parse index_values JSON: {}", e)))?; + let index_values: Vec = index_values_array + .into_iter() + .map(|value_str| { + if value_str.chars().all(|c| c.is_ascii_hexdigit()) && value_str.len() % 2 == 0 { + match hex::decode(&value_str) { + Ok(bytes) => Value::Bytes(bytes), + Err(_) => Value::Text(value_str), + } + } else { + Value::Text(value_str) + } + }) + .collect(); + + let vote_poll = + VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { + contract_id: contract_identifier, + document_type_name: document_type_name_str.to_string(), + index_name: index_name_str.to_string(), + index_values, + }); + + // ---- Resolve the vote choice -------------------------------------------- + let resource_vote_choice = match vote_choice { + VOTE_CHOICE_TOWARDS_IDENTITY => { + if contender_identity_id.is_null() { + return Err(invalid( + "contender_identity_id is required when vote_choice is TowardsIdentity (0)", + )); + } + let contender_str = cstr(contender_identity_id, "contender_identity_id")?; + let contender_bytes = bs58::decode(contender_str) + .into_vec() + .map_err(|e| invalid(&format!("Failed to decode contender id: {}", e)))?; + let contender_arr: [u8; 32] = contender_bytes + .try_into() + .map_err(|_| invalid("contender id must be exactly 32 bytes"))?; + ResourceVoteChoice::TowardsIdentity(Identifier::new(contender_arr)) + } + VOTE_CHOICE_ABSTAIN => ResourceVoteChoice::Abstain, + VOTE_CHOICE_LOCK => ResourceVoteChoice::Lock, + other => { + return Err(invalid(&format!( + "vote_choice must be 0 (TowardsIdentity), 1 (Abstain) or 2 (Lock); got {}", + other + ))); + } + }; + + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll, + resource_vote_choice, + })); + + // ---- pro_tx_hash --------------------------------------------------------- + let pro_tx_hash_slice = std::slice::from_raw_parts(voter_pro_tx_hash, 32); + let mut pro_tx_hash_arr = [0u8; 32]; + pro_tx_hash_arr.copy_from_slice(pro_tx_hash_slice); + let pro_tx_hash = Identifier::new(pro_tx_hash_arr); + + // ---- Derive the masternode voting key + signer -------------------------- + let network: Network = network.into(); + + // Hold the private key in a zeroizing buffer so it does not linger. + let key_slice = std::slice::from_raw_parts(voting_private_key, 32); + let mut key_array = Zeroizing::new([0u8; 32]); + key_array.copy_from_slice(key_slice); + + let signer = SingleKeySigner::new_from_slice(key_array.as_slice(), network) + .map_err(|e| invalid(&format!("Invalid voting private key: {}", e)))?; + + // The masternode voting key is the 20-byte hash160 of the (compressed) + // public key, packaged as an ECDSA_HASH160 / VOTING / HIGH key with id 0 — + // exactly the shape Platform assigns to voter identities + // (`get_voter_identity_key_v0`). `MasternodeVoteTransition` calls + // `masternode_voting_key.public_key_hash()` to derive the voter + // identifier, and `SingleKeySigner::can_sign_with` recomputes the same + // hash160 from the private key, so the two agree by construction. + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_byte_array(&key_array) + .map_err(|e| invalid(&format!("Invalid voting private key: {}", e)))?; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + let voting_address = hash160::Hash::hash(&public_key.serialize()).to_byte_array(); + + let masternode_voting_key: IdentityPublicKey = IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::VOTING, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_HASH160, + read_only: true, + data: BinaryData::new(voting_address.to_vec()), + disabled_at: None, + contract_bounds: None, + } + .into(); + + // ---- Broadcast ---------------------------------------------------------- + let wrapper = &*(sdk_handle as *const SDKWrapper); + let sdk = &wrapper.sdk; + + wrapper.runtime.block_on(async { + vote.put_to_platform_and_wait_for_response( + pro_tx_hash, + &masternode_voting_key, + sdk, + &signer, + None, + ) + .await + .map_err(FFIError::from) + })?; + + Ok(()) +} + +fn invalid(message: &str) -> FFIError { + FFIError::InvalidParameter(message.to_string()) +} + +unsafe fn cstr<'a>(ptr: *const c_char, field: &str) -> Result<&'a str, FFIError> { + CStr::from_ptr(ptr) + .to_str() + .map_err(|e| invalid(&format!("Invalid UTF-8 in {}: {}", field, e))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::create_mock_sdk_handle; + use crate::DashSDKErrorCode; + use std::ffi::CString; + + #[test] + fn test_cast_vote_null_handle() { + unsafe { + let pro_tx = [1u8; 32]; + let priv_key = [2u8; 32]; + let result = dash_sdk_contested_resource_cast_vote( + std::ptr::null(), + CString::new("test").unwrap().as_ptr(), + CString::new("domain").unwrap().as_ptr(), + CString::new("parentNameAndLabel").unwrap().as_ptr(), + CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), + VOTE_CHOICE_ABSTAIN, + std::ptr::null(), + pro_tx.as_ptr(), + priv_key.as_ptr(), + FFINetwork::Testnet, + ); + assert!(!result.error.is_null()); + } + } + + #[test] + fn test_cast_vote_towards_identity_requires_contender() { + let handle = create_mock_sdk_handle(); + unsafe { + let pro_tx = [1u8; 32]; + let priv_key = [2u8; 32]; + let result = dash_sdk_contested_resource_cast_vote( + handle, + CString::new("DPNS").unwrap().as_ptr(), + CString::new("domain").unwrap().as_ptr(), + CString::new("parentNameAndLabel").unwrap().as_ptr(), + CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), + VOTE_CHOICE_TOWARDS_IDENTITY, + std::ptr::null(), // missing contender id + pro_tx.as_ptr(), + priv_key.as_ptr(), + FFINetwork::Testnet, + ); + assert!(!result.error.is_null()); + let err = &*result.error; + assert_eq!(err.code, DashSDKErrorCode::InvalidParameter); + crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); + } + } + + #[test] + fn test_cast_vote_invalid_choice() { + let handle = create_mock_sdk_handle(); + unsafe { + let pro_tx = [1u8; 32]; + let priv_key = [2u8; 32]; + let result = dash_sdk_contested_resource_cast_vote( + handle, + CString::new("DPNS").unwrap().as_ptr(), + CString::new("domain").unwrap().as_ptr(), + CString::new("parentNameAndLabel").unwrap().as_ptr(), + CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), + 99, // invalid + std::ptr::null(), + pro_tx.as_ptr(), + priv_key.as_ptr(), + FFINetwork::Testnet, + ); + assert!(!result.error.is_null()); + let err = &*result.error; + assert_eq!(err.code, DashSDKErrorCode::InvalidParameter); + crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); + } + } +} diff --git a/packages/rs-sdk-ffi/src/contested_resource/transitions/mod.rs b/packages/rs-sdk-ffi/src/contested_resource/transitions/mod.rs new file mode 100644 index 00000000000..20176b7d774 --- /dev/null +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/mod.rs @@ -0,0 +1,5 @@ +// Contested resource transitions (write path) +pub mod cast_vote; + +// Re-export all public functions for convenient access +pub use cast_vote::dash_sdk_contested_resource_cast_vote; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 874ef15e29e..69dcfdd9f20 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -948,6 +948,98 @@ extension SDK { return try processJSONArrayResult(result) } + // MARK: - Contested Resource Vote (write path) + + /// Cast a masternode contested-resource (DPNS) vote and wait for the + /// platform response. + /// + /// Thin bridge over `dash_sdk_contested_resource_cast_vote`: it marshals + /// the vote poll, the choice, the masternode `proTxHash`, and the 32-byte + /// voting private key in, then surfaces success / failure out. All of the + /// transition assembly + signing + broadcast happens on the Rust side + /// (rs-sdk's `PutVote`); nothing is decided here. + /// + /// - Important: Contested-resource votes are cast by **masternodes** using + /// their masternode voting key (tied to a `proTxHash`). A regular wallet + /// is not a masternode and has no voting key, so a vote from such a + /// wallet reaches a deterministic authorization rejection on the platform + /// side. Supplying a genuine masternode `proTxHash` + voting private key + /// is required for an accepted vote. + /// + /// - Parameters: + /// - dataContractId: Base58 contested resource's data-contract id (DPNS + /// for username contests). + /// - documentTypeName: Contested document type (e.g. `"domain"`). + /// - indexName: Contested index name (e.g. `"parentNameAndLabel"`). + /// - indexValues: Index values identifying the contested resource + /// (e.g. `["dash", "alice"]`). + /// - choice: TowardsIdentity / Abstain / Lock. + /// - proTxHash: The masternode's 32-byte pro_tx_hash. + /// - votingPrivateKey: The masternode's 32-byte voting private key. The + /// matching `ECDSA_HASH160` voting public key and the signer are + /// derived from this on the Rust side; the key bytes are not retained. + public func castContestedResourceVote( + dataContractId: String, + documentTypeName: String, + indexName: String, + indexValues: [String], + choice: ContestedResourceVoteChoice, + proTxHash: Data, + votingPrivateKey: Data + ) async throws { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + guard proTxHash.count == 32 else { + throw SDKError.invalidParameter("proTxHash must be exactly 32 bytes") + } + guard votingPrivateKey.count == 32 else { + throw SDKError.invalidParameter("votingPrivateKey must be exactly 32 bytes") + } + + let indexValuesData = try JSONSerialization.data(withJSONObject: indexValues) + let indexValuesJson = String(data: indexValuesData, encoding: .utf8) ?? "[]" + + let voteChoiceTag = choice.ffiTag + let contenderId: String? = { + if case let .towardsIdentity(id) = choice { return id } + return nil + }() + let networkValue = network.ffiValue + + let result = proTxHash.withUnsafeBytes { proTxPtr in + votingPrivateKey.withUnsafeBytes { keyPtr in + dash_sdk_contested_resource_cast_vote( + handle, + dataContractId, + documentTypeName, + indexName, + indexValuesJson, + voteChoiceTag, + contenderId, + proTxPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), + keyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), + networkValue + ) + } + } + + try processVoidResult(result) + } + + /// Process a `DashSDKResult` that carries no payload on success (the + /// broadcast / put-style FFIs return `NoData`). Throws on the error arm, + /// otherwise returns. Frees the FFI-owned error string on the failure path. + private func processVoidResult(_ result: DashSDKResult) throws { + if let error = result.error { + let errorMessage = error.pointee.message != nil + ? String(cString: error.pointee.message!) + : "Unknown error" + dash_sdk_error_free(error) + throw SDKError.internalError(errorMessage) + } + } + /// Get vote polls by end date public func getVotePollsByEndDate( startTimeMs: UInt64?, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift index da50b9f539e..ba3b80430f7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift @@ -50,6 +50,30 @@ public enum ContestWinner: Sendable, Equatable { case locked } +/// A masternode's vote choice on a contested resource. Matches the Rust +/// `ResourceVoteChoice` enum and the `vote_choice` discriminant accepted by +/// `dash_sdk_contested_resource_cast_vote`: +/// `0` = TowardsIdentity, `1` = Abstain, `2` = Lock. +public enum ContestedResourceVoteChoice: Sendable, Equatable { + /// Vote for a specific contender, identified by its base58 identity id. + case towardsIdentity(String) + /// Abstain — counts toward the abstain tally. + case abstain + /// Lock — vote that nobody should win the contested resource. + case lock + + /// The `vote_choice` discriminant byte passed across the FFI. Kept in + /// lockstep with the `VOTE_CHOICE_*` constants in rs-sdk-ffi's + /// `cast_vote.rs`. + var ffiTag: UInt8 { + switch self { + case .towardsIdentity: return 0 + case .abstain: return 1 + case .lock: return 2 + } + } +} + // MARK: - FFI decoding extension ContestVoteState { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift index a2f0605f4fb..26a731b0da6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift @@ -36,6 +36,24 @@ struct ContestDetailView: View { @State private var isRefreshing = false @State private var errorMessage: String? + // MARK: - Vote-casting state + /// Presents the masternode vote sheet. + @State private var showVoteSheet = false + /// In-flight broadcast guard. + @State private var isCastingVote = false + /// Result banner shown after a cast attempt (success or the + /// deterministic authorization rejection a non-masternode hits). + @State private var voteResultMessage: String? + @State private var voteResultIsError = false + + /// DPNS contest poll shape — the same `(contract, document type, + /// index, index values)` tuple the read path uses. The contested + /// label lives at the second index value (`["dash", label]`). + private static let dpnsContractId = "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec" + private static let dpnsDocumentType = "domain" + private static let dpnsIndexName = "parentNameAndLabel" + private var dpnsIndexValues: [String] { ["dash", contestName] } + /// Current identity's 32-byte id, parsed from the base58 input /// parameter once. `nil` if the caller passed an unparseable id. private var currentIdentityData: Data? { @@ -82,6 +100,7 @@ struct ContestDetailView: View { contestHeaderSection contendersSection voteSummarySection + castVoteSection Section { VStack(alignment: .leading, spacing: 8) { @@ -104,6 +123,21 @@ struct ContestDetailView: View { await refreshVoteState() } } + .sheet(isPresented: $showVoteSheet) { + CastVoteSheet( + contestName: contestName, + contenders: sortedContenders, + currentIdentityData: currentIdentityData, + isCasting: isCastingVote, + onSubmit: { choice, proTxHashHex, votingKeyHex in + await castVote( + choice: choice, + proTxHashHex: proTxHashHex, + votingKeyHex: votingKeyHex + ) + } + ) + } } // MARK: - Sections @@ -259,6 +293,40 @@ struct ContestDetailView: View { } } + @ViewBuilder + private var castVoteSection: some View { + Section("Cast a Masternode Vote") { + if let message = voteResultMessage { + HStack(alignment: .top, spacing: 8) { + Image(systemName: voteResultIsError + ? "exclamationmark.triangle" + : "checkmark.circle.fill") + .foregroundColor(voteResultIsError ? .orange : .green) + Text(message) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + Button { + voteResultMessage = nil + showVoteSheet = true + } label: { + Label("Cast Vote…", systemImage: "hand.raised.fill") + } + .disabled(appState.sdk == nil || isCastingVote) + + // Contested-resource votes are masternode-only. Spell that + // out so a regular-wallet user understands why a broadcast + // is expected to be rejected, and what credentials make it + // succeed. + Text("Only masternodes can vote on contested names, using a masternode voting key tied to a pro_tx_hash. A regular wallet has no voting key, so a vote here will be rejected by Platform. Supply real masternode credentials in the sheet to cast an accepted vote.") + .font(.caption2) + .foregroundColor(.secondary) + } + } + @ViewBuilder private func singleContenderBanner(endTime: Date) -> some View { let totalDuration: TimeInterval = appState.currentNetwork == .mainnet @@ -398,4 +466,172 @@ struct ContestDetailView: View { errorMessage = "Refresh failed: \(error.localizedDescription)" } } + + // MARK: - Vote casting + + /// Broadcast a masternode contested-resource vote through the SDK. + /// + /// Marshals the hex inputs into `Data` and forwards to + /// `SDK.castContestedResourceVote`, which bridges straight to the + /// rs-sdk `PutVote` path. The transition is fully assembled, signed + /// and broadcast; a non-masternode caller reaches a deterministic + /// authorization rejection (surfaced here as an error banner). + private func castVote( + choice: ContestedResourceVoteChoice, + proTxHashHex: String, + votingKeyHex: String + ) async { + guard let sdk = appState.sdk else { + voteResultMessage = "SDK not initialized" + voteResultIsError = true + return + } + guard let proTxHash = Data(hexString: proTxHashHex), proTxHash.count == 32 else { + voteResultMessage = "pro_tx_hash must be 32 bytes (64 hex characters)." + voteResultIsError = true + return + } + guard let votingKey = Data(hexString: votingKeyHex), votingKey.count == 32 else { + voteResultMessage = "Voting private key must be 32 bytes (64 hex characters)." + voteResultIsError = true + return + } + + isCastingVote = true + defer { isCastingVote = false } + + do { + try await sdk.castContestedResourceVote( + dataContractId: Self.dpnsContractId, + documentTypeName: Self.dpnsDocumentType, + indexName: Self.dpnsIndexName, + indexValues: dpnsIndexValues, + choice: choice, + proTxHash: proTxHash, + votingPrivateKey: votingKey + ) + voteResultMessage = "Vote accepted by Platform." + voteResultIsError = false + showVoteSheet = false + // Pull fresh tallies after a successful cast. + await refreshVoteState() + } catch { + voteResultMessage = "Vote rejected: \(error.localizedDescription)" + voteResultIsError = true + showVoteSheet = false + } + } +} + +/// Modal for picking a vote choice and entering masternode credentials. +/// +/// Kept deliberately explicit about the masternode-key requirement — +/// the example app's regular wallets cannot satisfy it, so the inputs +/// are where a masternode operator would paste their pro_tx_hash and +/// voting private key (both hex). +private struct CastVoteSheet: View { + let contestName: String + let contenders: [ContestContender] + let currentIdentityData: Data? + let isCasting: Bool + let onSubmit: (ContestedResourceVoteChoice, String, String) async -> Void + + @Environment(\.dismiss) private var dismiss + + /// Selected contender identity (hex) or one of the special choices. + private enum Selection: Hashable { + case contender(String) // identity id hex + case abstain + case lock + } + + @State private var selection: Selection = .abstain + @State private var proTxHashHex = "" + @State private var votingKeyHex = "" + + var body: some View { + NavigationView { + Form { + Section("Voting on") { + Text("\(contestName).dash") + .font(.headline) + .foregroundColor(.blue) + } + + Section("Your Choice") { + Picker("Vote", selection: $selection) { + ForEach(contenders) { contender in + let hex = contender.identityId.toHexString() + HStack { + if contender.identityId == currentIdentityData { + Text("You") + } else { + Text(hex.prefix(12) + "…") + } + } + .tag(Selection.contender(hex)) + } + Text("Abstain").tag(Selection.abstain) + Text("Lock (no winner)").tag(Selection.lock) + } + .pickerStyle(.inline) + } + + Section("Masternode Credentials") { + TextField("pro_tx_hash (64 hex chars)", text: $proTxHashHex) + .font(.system(.caption, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + SecureField("voting private key (64 hex chars)", text: $votingKeyHex) + .font(.system(.caption, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Text("These belong to a masternode. The voting public key (ECDSA_HASH160) and signer are derived from the private key on the Rust side; the key bytes are not stored.") + .font(.caption2) + .foregroundColor(.secondary) + } + + Section { + Button { + Task { + await onSubmit(resolvedChoice, proTxHashHex, votingKeyHex) + } + } label: { + if isCasting { + HStack { + ProgressView() + Text("Broadcasting…") + } + } else { + Text("Submit Vote") + } + } + .disabled(isCasting || proTxHashHex.isEmpty || votingKeyHex.isEmpty) + } + } + .navigationTitle("Cast Vote") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isCasting) + } + } + } + } + + /// Translate the picker selection into the SDK vote-choice enum. + private var resolvedChoice: ContestedResourceVoteChoice { + switch selection { + case .contender(let hex): + // The FFI expects a base58 identity id; convert from the + // hex the contender rows carry. + let base58 = Data(hexString: hex)?.toBase58String() ?? hex + return .towardsIdentity(base58) + case .abstain: + return .abstain + case .lock: + return .lock + } + } } From 12c8fb34f1f9e05715617da3e73e082de4d25966 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 13 Jun 2026 21:20:56 +0200 Subject: [PATCH 2/4] refactor(rs-sdk-ffi): harden vote-choice FFI enum and hex input validation Address review feedback on the contested-resource vote path: - Replace the three bare `pub const u8` vote-choice constants with a `#[repr(u8)]` `ContestedResourceVoteChoiceFFI` enum and force-emit it via the cbindgen `[export] include` list, so the discriminants live in the generated C header as a single source of truth. The `vote_choice` FFI parameter stays a plain `u8` (a C caller can pass any byte, and materializing an out-of-range `#[repr(u8)]` value would be UB), and the internal match compares against the enum's `as u8` discriminants with a defensive `other` arm. Swift's `ffiTag` now returns the generated `ContestedResourceVoteChoiceFFI` type instead of a bare `UInt8` so the two sides can't silently drift. - Harden the cast-vote sheet in ContestDetailView: `Data(hexString:)` decodes `count / 2` bytes, so an odd-length (e.g. 65-char) pro_tx_hash or voting key silently dropped its trailing nibble and still passed the `count == 32` guard, signing with the wrong key. Validate the trimmed hex length is exactly 64 before decoding both inputs. - Use the real base58 DPNS contract id in the cast-vote tests so they pass the 32-byte length check and actually reach the missing-contender and invalid-vote-choice branches they assert on, instead of short-circuiting on a bogus "DPNS" contract id. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk-ffi/cbindgen.toml | 2 +- .../transitions/cast_vote.rs | 64 +++++++++++++++---- .../PlatformWallet/ContestVoteState.swift | 20 ++++-- .../Views/ContestDetailView.swift | 15 ++++- 4 files changed, 77 insertions(+), 24 deletions(-) diff --git a/packages/rs-sdk-ffi/cbindgen.toml b/packages/rs-sdk-ffi/cbindgen.toml index 8813a02e04b..957277506bc 100644 --- a/packages/rs-sdk-ffi/cbindgen.toml +++ b/packages/rs-sdk-ffi/cbindgen.toml @@ -15,7 +15,7 @@ documentation_style = "c99" [defines] [export] -include = ["dash_sdk_*", "dash_core_*", "dash_unified_sdk_*"] +include = ["dash_sdk_*", "dash_core_*", "dash_unified_sdk_*", "ContestedResourceVoteChoiceFFI"] # Exclude types that come from key-wallet-ffi to avoid duplication exclude = ["FFIAccountType", "FFIAccountTypePreference", "FFIAccountTypeUsed", "FFIAccountCreationOptionType"] prefix = "" diff --git a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs index 04363f65174..39471e4ac86 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs @@ -53,13 +53,32 @@ use simple_signer::SingleKeySigner; use std::ffi::{c_char, CStr}; use zeroize::Zeroizing; -/// Discriminant for the resource vote choice, mirroring +/// Resource vote choice discriminant, mirroring /// `ResourceVoteChoice::try_from((i32, Option>))` in rs-dpp: /// `0` = TowardsIdentity (requires `contender_identity_id`), `1` = Abstain, /// `2` = Lock. -pub const VOTE_CHOICE_TOWARDS_IDENTITY: u8 = 0; -pub const VOTE_CHOICE_ABSTAIN: u8 = 1; -pub const VOTE_CHOICE_LOCK: u8 = 2; +/// +/// This `#[repr(u8)]` enum exists so cbindgen emits the discriminant values +/// into the generated C header (a bare `pub const u8` is not exported), +/// giving Swift a single source of truth instead of hand-mirroring `0/1/2`. +/// It is force-emitted via the `[export] include` list in `cbindgen.toml`. +/// +/// The `vote_choice` FFI parameter is deliberately a plain `u8`, **not** this +/// enum: a C/Swift caller can pass any byte, and materializing an +/// out-of-range value as a `#[repr(u8)]` enum is undefined behavior. Keeping +/// the boundary type a `u8` lets [`cast_vote_inner`] validate the value and +/// reject anything outside `0..=2` with `InvalidParameter` instead of risking +/// UB. The enum's `as u8` discriminants are the comparison source of truth. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContestedResourceVoteChoiceFFI { + /// Vote for a specific contender; requires `contender_identity_id`. + TowardsIdentity = 0, + /// Abstain from the contest. + Abstain = 1, + /// Vote that nobody should win the contested resource. + Lock = 2, +} /// Cast a masternode contested-resource vote and wait for the response. /// @@ -77,7 +96,10 @@ pub const VOTE_CHOICE_LOCK: u8 = 2; /// * `index_values_json` — JSON array of index values. Each element is either /// a hex-encoded byte string (decoded to `Value::Bytes`) or a plain string /// (`Value::Text`), matching the parsing used by the vote-state query FFI. -/// * `vote_choice` — `0` = TowardsIdentity, `1` = Abstain, `2` = Lock. +/// * `vote_choice` — discriminant byte matching +/// [`ContestedResourceVoteChoiceFFI`]: `TowardsIdentity` (`0`), `Abstain` +/// (`1`) or `Lock` (`2`). Any other value is rejected with +/// `InvalidParameter`. /// * `contender_identity_id` — base58-encoded contender identity; required /// (and only used) when `vote_choice == 0`, otherwise ignored / may be null. /// * `voter_pro_tx_hash` — pointer to the masternode's 32-byte pro_tx_hash. @@ -208,8 +230,13 @@ unsafe fn cast_vote_inner( }); // ---- Resolve the vote choice -------------------------------------------- + // Compare the raw `u8` against the enum's `as u8` discriminants rather than + // taking the enum by value: a C caller can hand us any byte, and an + // out-of-range value is not a valid `#[repr(u8)]` discriminant. Matching on + // the byte keeps a defensive `other` arm that rejects such values with + // `InvalidParameter` instead of risking undefined behavior. let resource_vote_choice = match vote_choice { - VOTE_CHOICE_TOWARDS_IDENTITY => { + x if x == ContestedResourceVoteChoiceFFI::TowardsIdentity as u8 => { if contender_identity_id.is_null() { return Err(invalid( "contender_identity_id is required when vote_choice is TowardsIdentity (0)", @@ -224,8 +251,8 @@ unsafe fn cast_vote_inner( .map_err(|_| invalid("contender id must be exactly 32 bytes"))?; ResourceVoteChoice::TowardsIdentity(Identifier::new(contender_arr)) } - VOTE_CHOICE_ABSTAIN => ResourceVoteChoice::Abstain, - VOTE_CHOICE_LOCK => ResourceVoteChoice::Lock, + x if x == ContestedResourceVoteChoiceFFI::Abstain as u8 => ResourceVoteChoice::Abstain, + x if x == ContestedResourceVoteChoiceFFI::Lock as u8 => ResourceVoteChoice::Lock, other => { return Err(invalid(&format!( "vote_choice must be 0 (TowardsIdentity), 1 (Abstain) or 2 (Lock); got {}", @@ -317,6 +344,12 @@ mod tests { use crate::DashSDKErrorCode; use std::ffi::CString; + /// Real base58 DPNS data-contract id (32 bytes). Using the genuine id + /// means the bs58-decode + 32-byte length check passes, so tests reach + /// the branches they claim to cover (missing-contender, vote-choice match) + /// instead of short-circuiting on a bogus contract id. + const DPNS_CONTRACT_ID: &str = "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec"; + #[test] fn test_cast_vote_null_handle() { unsafe { @@ -324,11 +357,11 @@ mod tests { let priv_key = [2u8; 32]; let result = dash_sdk_contested_resource_cast_vote( std::ptr::null(), - CString::new("test").unwrap().as_ptr(), + CString::new(DPNS_CONTRACT_ID).unwrap().as_ptr(), CString::new("domain").unwrap().as_ptr(), CString::new("parentNameAndLabel").unwrap().as_ptr(), CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), - VOTE_CHOICE_ABSTAIN, + ContestedResourceVoteChoiceFFI::Abstain as u8, std::ptr::null(), pro_tx.as_ptr(), priv_key.as_ptr(), @@ -346,11 +379,11 @@ mod tests { let priv_key = [2u8; 32]; let result = dash_sdk_contested_resource_cast_vote( handle, - CString::new("DPNS").unwrap().as_ptr(), + CString::new(DPNS_CONTRACT_ID).unwrap().as_ptr(), CString::new("domain").unwrap().as_ptr(), CString::new("parentNameAndLabel").unwrap().as_ptr(), CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), - VOTE_CHOICE_TOWARDS_IDENTITY, + ContestedResourceVoteChoiceFFI::TowardsIdentity as u8, std::ptr::null(), // missing contender id pro_tx.as_ptr(), priv_key.as_ptr(), @@ -369,13 +402,16 @@ mod tests { unsafe { let pro_tx = [1u8; 32]; let priv_key = [2u8; 32]; + // A C caller can pass any byte for the u8 `vote_choice`; an + // out-of-range value must hit the defensive `other` arm and be + // rejected with `InvalidParameter`. let result = dash_sdk_contested_resource_cast_vote( handle, - CString::new("DPNS").unwrap().as_ptr(), + CString::new(DPNS_CONTRACT_ID).unwrap().as_ptr(), CString::new("domain").unwrap().as_ptr(), CString::new("parentNameAndLabel").unwrap().as_ptr(), CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), - 99, // invalid + 99, // invalid discriminant std::ptr::null(), pro_tx.as_ptr(), priv_key.as_ptr(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift index ba3b80430f7..c5e493dbc08 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ContestVoteState.swift @@ -1,4 +1,5 @@ import Foundation +import DashSDKFFI /// Snapshot of a DPNS contest's current vote state. /// @@ -62,14 +63,19 @@ public enum ContestedResourceVoteChoice: Sendable, Equatable { /// Lock — vote that nobody should win the contested resource. case lock - /// The `vote_choice` discriminant byte passed across the FFI. Kept in - /// lockstep with the `VOTE_CHOICE_*` constants in rs-sdk-ffi's - /// `cast_vote.rs`. - var ffiTag: UInt8 { + /// The `vote_choice` value passed across the FFI, typed as the + /// cbindgen-generated `ContestedResourceVoteChoiceFFI` rather than a bare + /// `UInt8`. `rs-sdk-ffi` emits that `#[repr(u8)]` enum into the C header + /// (a u8 typedef) as the single source of truth for the discriminants, so + /// binding the bridge to the generated symbol keeps Swift from drifting + /// from the Rust values. The FFI function takes a `u8`, which this maps + /// to cleanly. The raw values mirror the Rust variants: + /// `TowardsIdentity = 0`, `Abstain = 1`, `Lock = 2`. + var ffiTag: ContestedResourceVoteChoiceFFI { switch self { - case .towardsIdentity: return 0 - case .abstain: return 1 - case .lock: return 2 + case .towardsIdentity: return ContestedResourceVoteChoiceFFI(0) + case .abstain: return ContestedResourceVoteChoiceFFI(1) + case .lock: return ContestedResourceVoteChoiceFFI(2) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift index 26a731b0da6..82cd359098d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift @@ -486,12 +486,23 @@ struct ContestDetailView: View { voteResultIsError = true return } - guard let proTxHash = Data(hexString: proTxHashHex), proTxHash.count == 32 else { + // `Data(hexString:)` decodes `count / 2` bytes stepping by two, so an + // odd-length (e.g. 65-char) string silently drops its trailing nibble + // and still yields 32 bytes — the `count == 32` guard alone would pass + // a malformed key. Validate the trimmed hex length is exactly 64 (and + // therefore even) before decoding so a wrong key can't slip through. + let normalizedProTxHashHex = proTxHashHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedProTxHashHex.count == 64, + let proTxHash = Data(hexString: normalizedProTxHashHex), + proTxHash.count == 32 else { voteResultMessage = "pro_tx_hash must be 32 bytes (64 hex characters)." voteResultIsError = true return } - guard let votingKey = Data(hexString: votingKeyHex), votingKey.count == 32 else { + let normalizedVotingKeyHex = votingKeyHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedVotingKeyHex.count == 64, + let votingKey = Data(hexString: normalizedVotingKeyHex), + votingKey.count == 32 else { voteResultMessage = "Voting private key must be 32 bytes (64 hex characters)." voteResultIsError = true return From da40c87cbe83d8896ce28f8be1834bf16bb7425a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 13 Jun 2026 21:48:07 +0200 Subject: [PATCH 3/4] test(rs-sdk-ffi): bind CString temporaries so cast_vote test pointers stay valid Co-Authored-By: Claude Opus 4.8 (1M context) --- .../transitions/cast_vote.rs | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs index 39471e4ac86..328df8cc3bc 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs @@ -352,15 +352,22 @@ mod tests { #[test] fn test_cast_vote_null_handle() { + // Bind every CString to a local so the backing buffer outlives the FFI + // call; passing `CString::new(..).unwrap().as_ptr()` inline drops the + // temporary before the call reads the pointer (use-after-free). + let contract_id = CString::new(DPNS_CONTRACT_ID).unwrap(); + let document_type_name = CString::new("domain").unwrap(); + let index_name = CString::new("parentNameAndLabel").unwrap(); + let index_values = CString::new(r#"["dash","alice"]"#).unwrap(); unsafe { let pro_tx = [1u8; 32]; let priv_key = [2u8; 32]; let result = dash_sdk_contested_resource_cast_vote( std::ptr::null(), - CString::new(DPNS_CONTRACT_ID).unwrap().as_ptr(), - CString::new("domain").unwrap().as_ptr(), - CString::new("parentNameAndLabel").unwrap().as_ptr(), - CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), + contract_id.as_ptr(), + document_type_name.as_ptr(), + index_name.as_ptr(), + index_values.as_ptr(), ContestedResourceVoteChoiceFFI::Abstain as u8, std::ptr::null(), pro_tx.as_ptr(), @@ -374,15 +381,22 @@ mod tests { #[test] fn test_cast_vote_towards_identity_requires_contender() { let handle = create_mock_sdk_handle(); + // Bind every CString to a local so the backing buffer outlives the FFI + // call; passing `CString::new(..).unwrap().as_ptr()` inline drops the + // temporary before the call reads the pointer (use-after-free). + let contract_id = CString::new(DPNS_CONTRACT_ID).unwrap(); + let document_type_name = CString::new("domain").unwrap(); + let index_name = CString::new("parentNameAndLabel").unwrap(); + let index_values = CString::new(r#"["dash","alice"]"#).unwrap(); unsafe { let pro_tx = [1u8; 32]; let priv_key = [2u8; 32]; let result = dash_sdk_contested_resource_cast_vote( handle, - CString::new(DPNS_CONTRACT_ID).unwrap().as_ptr(), - CString::new("domain").unwrap().as_ptr(), - CString::new("parentNameAndLabel").unwrap().as_ptr(), - CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), + contract_id.as_ptr(), + document_type_name.as_ptr(), + index_name.as_ptr(), + index_values.as_ptr(), ContestedResourceVoteChoiceFFI::TowardsIdentity as u8, std::ptr::null(), // missing contender id pro_tx.as_ptr(), @@ -399,6 +413,13 @@ mod tests { #[test] fn test_cast_vote_invalid_choice() { let handle = create_mock_sdk_handle(); + // Bind every CString to a local so the backing buffer outlives the FFI + // call; passing `CString::new(..).unwrap().as_ptr()` inline drops the + // temporary before the call reads the pointer (use-after-free). + let contract_id = CString::new(DPNS_CONTRACT_ID).unwrap(); + let document_type_name = CString::new("domain").unwrap(); + let index_name = CString::new("parentNameAndLabel").unwrap(); + let index_values = CString::new(r#"["dash","alice"]"#).unwrap(); unsafe { let pro_tx = [1u8; 32]; let priv_key = [2u8; 32]; @@ -407,10 +428,10 @@ mod tests { // rejected with `InvalidParameter`. let result = dash_sdk_contested_resource_cast_vote( handle, - CString::new(DPNS_CONTRACT_ID).unwrap().as_ptr(), - CString::new("domain").unwrap().as_ptr(), - CString::new("parentNameAndLabel").unwrap().as_ptr(), - CString::new(r#"["dash","alice"]"#).unwrap().as_ptr(), + contract_id.as_ptr(), + document_type_name.as_ptr(), + index_name.as_ptr(), + index_values.as_ptr(), 99, // invalid discriminant std::ptr::null(), pro_tx.as_ptr(), From d85d9fc96e814392519982772c7590bc8acd4b0f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 13 Jun 2026 22:19:44 +0200 Subject: [PATCH 4/4] fix(rs-sdk-ffi): require explicit 0x tag for byte index values and tidy vote FFI Replace the unsafe hex/text auto-detection on the contested-resource vote path with an explicit type tag: an index value is decoded as `Value::Bytes` only when it carries a `"0x"` prefix (hex-decode the remainder), otherwise it is taken verbatim as `Value::Text`. The previous content heuristic silently re-encoded even-length all-hex DPNS labels (`"abcd"`, `"cafe"`, `"deadbeef"`, `"1234"`) as bytes, so a signed-and-broadcast vote could target the wrong vote poll with no signal back to the caller. The parsing now lives in one shared `parse_index_value` helper in `contested_resource/mod.rs`, called from the cast-vote write path and the three read-path query FFIs (`vote_state`, `resources`, `voters_for_identity`) so write and read stay in lockstep. Added unit tests asserting an even-length all-hex label without `0x` decodes as `Value::Text` and a `"0x..."` value decodes as `Value::Bytes`. Updated the doc comments that described the old hex-auto-detect behavior. Swift: `resolvedChoice` in ContestDetailView no longer falls back to the raw hex string when `Data(hexString:)` returns nil; it now returns an optional and the Submit button surfaces a clear error instead of passing un-decodable hex downstream to the FFI as if it were base58. Also free the `DashSDKError` allocation in the three cast_vote unit tests via `dash_sdk_error_free` after the assertions, so the error struct and message string no longer leak (models the caller contract, keeps leak sanitizers happy). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rs-sdk-ffi/src/contested_resource/mod.rs | 86 +++++++++++++++++++ .../contested_resource/queries/resources.rs | 36 +++----- .../contested_resource/queries/vote_state.rs | 24 ++---- .../queries/voters_for_identity.rs | 24 ++---- .../transitions/cast_vote.rs | 46 ++++++---- .../Views/ContestDetailView.swift | 34 ++++++-- 6 files changed, 169 insertions(+), 81 deletions(-) diff --git a/packages/rs-sdk-ffi/src/contested_resource/mod.rs b/packages/rs-sdk-ffi/src/contested_resource/mod.rs index cb6cab2ff19..1fd02d706a9 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/mod.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/mod.rs @@ -5,3 +5,89 @@ mod transitions; // Re-export all public functions pub use queries::*; pub use transitions::*; + +use dash_sdk::dpp::platform_value::Value; + +/// Decode a single JSON index-value string into a platform [`Value`] using an +/// **explicit** type tag rather than guessing by content. +/// +/// * A string with a `"0x"` prefix is hex-decoded into [`Value::Bytes`] (the +/// remainder after `0x` must be valid, even-length hex). +/// * Any other string is taken verbatim as [`Value::Text`]. +/// +/// This is the single source of truth shared by the contested-resource write +/// path ([`crate::contested_resource::transitions::cast_vote`]) and the read +/// path query FFIs (`vote_state`, `resources`, `voters_for_identity`) so a poll +/// a caller can read is the same poll it votes on. +/// +/// The previous heuristic decoded any even-length all-ASCII-hex string as bytes. +/// That is unsafe on the signing path: a legitimate DPNS text label that happens +/// to be even-length all-hex (e.g. `"abcd"`, `"cafe"`, `"deadbeef"`, `"1234"`) +/// would be silently re-encoded as `Value::Bytes`, targeting a different vote +/// poll than the operator intended. DPNS index values are text labels, so +/// typical callers pass plain text (no `0x`) and correctly get `Value::Text`. +pub(crate) fn parse_index_value(value_str: String) -> Result { + match value_str.strip_prefix("0x") { + Some(hex_str) => { + let bytes = hex::decode(hex_str).map_err(|e| { + format!( + "Index value '{}' has a 0x prefix but is not valid hex: {}", + value_str, e + ) + })?; + Ok(Value::Bytes(bytes)) + } + None => Ok(Value::Text(value_str)), + } +} + +#[cfg(test)] +mod tests { + use super::parse_index_value; + use dash_sdk::dpp::platform_value::Value; + + #[test] + fn even_length_all_hex_label_without_prefix_is_text() { + // A DPNS-style label that is even-length all-hex must stay text on + // both the read and write paths — the old content heuristic would have + // re-encoded these as bytes. + for label in ["abcd", "cafe", "face", "deadbeef", "1234"] { + assert_eq!( + parse_index_value(label.to_string()).unwrap(), + Value::Text(label.to_string()), + "{label} should decode as Value::Text" + ); + } + } + + #[test] + fn zero_x_prefixed_value_is_bytes() { + assert_eq!( + parse_index_value("0xabcd".to_string()).unwrap(), + Value::Bytes(vec![0xab, 0xcd]) + ); + assert_eq!( + parse_index_value("0xdeadbeef".to_string()).unwrap(), + Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef]) + ); + } + + #[test] + fn plain_text_label_is_text() { + assert_eq!( + parse_index_value("dash".to_string()).unwrap(), + Value::Text("dash".to_string()) + ); + assert_eq!( + parse_index_value("alice".to_string()).unwrap(), + Value::Text("alice".to_string()) + ); + } + + #[test] + fn zero_x_prefixed_invalid_hex_errors() { + assert!(parse_index_value("0xnothex".to_string()).is_err()); + // odd-length hex after the prefix is rejected too + assert!(parse_index_value("0xabc".to_string()).is_err()); + } +} diff --git a/packages/rs-sdk-ffi/src/contested_resource/queries/resources.rs b/packages/rs-sdk-ffi/src/contested_resource/queries/resources.rs index df641df86a0..ef41b75814b 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/queries/resources.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/queries/resources.rs @@ -13,8 +13,11 @@ use std::ffi::{c_char, c_void, CStr, CString}; /// * `contract_id` - Base58-encoded contract identifier /// * `document_type_name` - Name of the document type /// * `index_name` - Name of the index -/// * `start_index_values_json` - JSON array of hex-encoded start index values -/// * `end_index_values_json` - JSON array of hex-encoded end index values +/// * `start_index_values_json` - JSON array of start index values. A +/// `"0x"`-prefixed element is hex-decoded to bytes; any other element is +/// taken verbatim as text (matches the cast-vote write path). +/// * `end_index_values_json` - JSON array of end index values, parsed with the +/// same `"0x"`-prefix-means-bytes convention. /// * `count` - Maximum number of resources to return /// * `order_ascending` - Whether to order results in ascending order /// @@ -147,7 +150,10 @@ fn get_contested_resources( let contract_id = dash_sdk::platform::Identifier::new(contract_id); - // Parse start index values: hex-like -> Bytes, otherwise Text to match vectors + // Parse start index values via the shared explicit-tag helper: a + // `"0x"`-prefixed value is hex-decoded to `Value::Bytes`, anything else + // is verbatim `Value::Text`. Shared with the cast-vote write path so + // read and write stay in lockstep. let start_index_values = if start_index_values_json.is_null() { Vec::new() } else { @@ -161,20 +167,11 @@ fn get_contested_resources( start_values_array .into_iter() - .map(|val| { - if val.chars().all(|c| c.is_ascii_hexdigit()) && val.len() % 2 == 0 { - match hex::decode(&val) { - Ok(bytes) => Ok(Value::Bytes(bytes)), - Err(_) => Ok(Value::Text(val)), - } - } else { - Ok(Value::Text(val)) - } - }) + .map(crate::contested_resource::parse_index_value) .collect::, String>>()? }; - // Parse end index values: hex-like -> Bytes, otherwise Text + // Parse end index values via the same shared explicit-tag helper. let end_index_values = if end_index_values_json.is_null() { Vec::new() } else { @@ -188,16 +185,7 @@ fn get_contested_resources( end_values_array .into_iter() - .map(|val| { - if val.chars().all(|c| c.is_ascii_hexdigit()) && val.len() % 2 == 0 { - match hex::decode(&val) { - Ok(bytes) => Ok(Value::Bytes(bytes)), - Err(_) => Ok(Value::Text(val)), - } - } else { - Ok(Value::Text(val)) - } - }) + .map(crate::contested_resource::parse_index_value) .collect::, String>>()? }; diff --git a/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs b/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs index c40cf60c300..5d1b1e3ac8b 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs @@ -16,7 +16,9 @@ use std::ffi::{c_char, c_void, CStr, CString}; /// * `contract_id` - Base58-encoded contract identifier /// * `document_type_name` - Name of the document type /// * `index_name` - Name of the index -/// * `index_values_json` - JSON array of hex-encoded index values +/// * `index_values_json` - JSON array of index values. A `"0x"`-prefixed +/// element is hex-decoded to bytes; any other element is taken verbatim as +/// text (matches the cast-vote write path). /// * `result_type` - Result type (0=DOCUMENTS, 1=VOTE_TALLY, 2=DOCUMENTS_AND_VOTE_TALLY) /// * `allow_include_locked_and_abstaining_vote_tally` - Whether to include locked and abstaining votes /// * `count` - Maximum number of results to return @@ -162,24 +164,12 @@ fn get_contested_resource_vote_state( let index_values_array: Vec = serde_json::from_str(index_values_str) .map_err(|e| format!("Failed to parse index values JSON: {}", e))?; + // Explicit type tag: a `"0x"`-prefixed value is hex-decoded to + // `Value::Bytes`, anything else is verbatim `Value::Text`. Shared with + // the cast-vote write path so read and write stay in lockstep. let index_values: Vec = index_values_array .into_iter() - .map(|value_str| { - // Check if the value is hex-encoded (all characters are valid hex) - if value_str.chars().all(|c| c.is_ascii_hexdigit()) && value_str.len() % 2 == 0 { - // Try to decode as hex - match hex::decode(&value_str) { - Ok(bytes) => Ok(Value::Bytes(bytes)), - Err(_) => { - // If hex decode fails, treat as text - Ok(Value::Text(value_str)) - } - } - } else { - // Not hex, treat as text string - Ok(Value::Text(value_str)) - } - }) + .map(crate::contested_resource::parse_index_value) .collect::, String>>()?; let result_type = match result_type { diff --git a/packages/rs-sdk-ffi/src/contested_resource/queries/voters_for_identity.rs b/packages/rs-sdk-ffi/src/contested_resource/queries/voters_for_identity.rs index 74429b5bf37..fa8f4076cd8 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/queries/voters_for_identity.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/queries/voters_for_identity.rs @@ -14,7 +14,9 @@ use std::ffi::{c_char, c_void, CStr, CString}; /// * `contract_id` - Base58-encoded contract identifier /// * `document_type_name` - Name of the document type /// * `index_name` - Name of the index -/// * `index_values_json` - JSON array of hex-encoded index values +/// * `index_values_json` - JSON array of index values. A `"0x"`-prefixed +/// element is hex-decoded to bytes; any other element is taken verbatim as +/// text (matches the cast-vote write path). /// * `contestant_id` - Base58-encoded contestant identifier /// * `count` - Maximum number of voters to return /// * `order_ascending` - Whether to order results in ascending order @@ -178,24 +180,12 @@ fn get_contested_resource_voters_for_identity( let index_values_array: Vec = serde_json::from_str(index_values_str) .map_err(|e| format!("Failed to parse index values JSON: {}", e))?; + // Explicit type tag: a `"0x"`-prefixed value is hex-decoded to + // `Value::Bytes`, anything else is verbatim `Value::Text`. Shared with + // the cast-vote write path so read and write stay in lockstep. let index_values: Vec = index_values_array .into_iter() - .map(|value_str| { - // Check if the value is hex-encoded (all characters are valid hex) - if value_str.chars().all(|c| c.is_ascii_hexdigit()) && value_str.len() % 2 == 0 { - // Try to decode as hex - match hex::decode(&value_str) { - Ok(bytes) => Ok(Value::Bytes(bytes)), - Err(_) => { - // If hex decode fails, treat as text - Ok(Value::Text(value_str)) - } - } - } else { - // Not hex, treat as text string - Ok(Value::Text(value_str)) - } - }) + .map(crate::contested_resource::parse_index_value) .collect::, String>>()?; let vote_poll = ContestedDocumentResourceVotePoll { diff --git a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs index 328df8cc3bc..180e56ebcf4 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs @@ -93,9 +93,11 @@ pub enum ContestedResourceVoteChoiceFFI { /// (DPNS for username contests). /// * `document_type_name` — contested document type (e.g. `"domain"`). /// * `index_name` — contested index name (e.g. `"parentNameAndLabel"`). -/// * `index_values_json` — JSON array of index values. Each element is either -/// a hex-encoded byte string (decoded to `Value::Bytes`) or a plain string -/// (`Value::Text`), matching the parsing used by the vote-state query FFI. +/// * `index_values_json` — JSON array of index values. Each element is decoded +/// via an explicit type tag: a `"0x"`-prefixed string is hex-decoded to +/// `Value::Bytes`, and any other string is taken verbatim as `Value::Text`. +/// DPNS index values are text labels, so typical callers pass plain text +/// (no `0x`). This matches the parsing used by the vote-state query FFI. /// * `vote_choice` — discriminant byte matching /// [`ContestedResourceVoteChoiceFFI`]: `TowardsIdentity` (`0`), `Abstain` /// (`1`) or `Lock` (`2`). Any other value is rejected with @@ -202,24 +204,21 @@ unsafe fn cast_vote_inner( .map_err(|_| invalid("contract id must be exactly 32 bytes"))?; let contract_identifier = Identifier::new(contract_id_arr); - // Same index-value parsing as the vote-state query FFI: hex → Bytes, - // otherwise Text. Keeping the two FFIs in lockstep means a poll that a - // caller can read with `get_vote_state` is the same poll it votes on here. + // Same index-value parsing as the vote-state query FFI, via the shared + // `parse_index_value` helper: a `"0x"`-prefixed value is hex-decoded to + // `Value::Bytes`, anything else is taken verbatim as `Value::Text`. Using + // the single shared helper keeps the read and write paths in lockstep, so a + // poll a caller can read with `get_vote_state` is the same poll it votes on + // here. An explicit `0x` tag is required (rather than guessing by content) + // because this is a signing surface: a legit DPNS text label that happens + // to be even-length all-hex must not be silently re-encoded as bytes. let index_values_array: Vec = serde_json::from_str(index_values_str) .map_err(|e| invalid(&format!("Failed to parse index_values JSON: {}", e)))?; let index_values: Vec = index_values_array .into_iter() - .map(|value_str| { - if value_str.chars().all(|c| c.is_ascii_hexdigit()) && value_str.len() % 2 == 0 { - match hex::decode(&value_str) { - Ok(bytes) => Value::Bytes(bytes), - Err(_) => Value::Text(value_str), - } - } else { - Value::Text(value_str) - } - }) - .collect(); + .map(crate::contested_resource::parse_index_value) + .collect::, String>>() + .map_err(|e| invalid(&e))?; let vote_poll = VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { @@ -341,7 +340,7 @@ unsafe fn cstr<'a>(ptr: *const c_char, field: &str) -> Result<&'a str, FFIError> mod tests { use super::*; use crate::test_utils::test_utils::create_mock_sdk_handle; - use crate::DashSDKErrorCode; + use crate::{dash_sdk_error_free, DashSDKErrorCode}; use std::ffi::CString; /// Real base58 DPNS data-contract id (32 bytes). Using the genuine id @@ -375,6 +374,9 @@ mod tests { FFINetwork::Testnet, ); assert!(!result.error.is_null()); + // Free the DashSDKError (struct + message string) the result owns; + // models the caller contract and keeps leak sanitizers happy. + dash_sdk_error_free(result.error); } } @@ -406,6 +408,10 @@ mod tests { assert!(!result.error.is_null()); let err = &*result.error; assert_eq!(err.code, DashSDKErrorCode::InvalidParameter); + // Free the DashSDKError (struct + message string) the result owns, + // after the last use of `err`; models the caller contract and keeps + // leak sanitizers happy. + dash_sdk_error_free(result.error); crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); } } @@ -441,6 +447,10 @@ mod tests { assert!(!result.error.is_null()); let err = &*result.error; assert_eq!(err.code, DashSDKErrorCode::InvalidParameter); + // Free the DashSDKError (struct + message string) the result owns, + // after the last use of `err`; models the caller contract and keeps + // leak sanitizers happy. + dash_sdk_error_free(result.error); crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift index 82cd359098d..0a674a180da 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ContestDetailView.swift @@ -559,6 +559,9 @@ private struct CastVoteSheet: View { @State private var selection: Selection = .abstain @State private var proTxHashHex = "" @State private var votingKeyHex = "" + /// Surfaced when the selected contender's hex id fails to decode, so we + /// never pass un-decodable hex downstream to the FFI as if it were base58. + @State private var choiceError: String? var body: some View { NavigationView { @@ -604,8 +607,16 @@ private struct CastVoteSheet: View { Section { Button { + // Guarded decode: if the selected contender's hex id + // can't be decoded, surface a clear error instead of + // passing raw hex to the FFI as if it were base58. + guard let choice = resolvedChoice else { + choiceError = "Could not decode the selected contender's identity id." + return + } + choiceError = nil Task { - await onSubmit(resolvedChoice, proTxHashHex, votingKeyHex) + await onSubmit(choice, proTxHashHex, votingKeyHex) } } label: { if isCasting { @@ -618,6 +629,12 @@ private struct CastVoteSheet: View { } } .disabled(isCasting || proTxHashHex.isEmpty || votingKeyHex.isEmpty) + + if let choiceError { + Text(choiceError) + .font(.caption) + .foregroundColor(.orange) + } } } .navigationTitle("Cast Vote") @@ -632,12 +649,19 @@ private struct CastVoteSheet: View { } /// Translate the picker selection into the SDK vote-choice enum. - private var resolvedChoice: ContestedResourceVoteChoice { + /// + /// Returns `nil` when a contender's hex identity id fails to decode, so the + /// caller can surface a clear error instead of passing un-decodable hex + /// downstream to the FFI as if it were base58. + private var resolvedChoice: ContestedResourceVoteChoice? { switch selection { case .contender(let hex): - // The FFI expects a base58 identity id; convert from the - // hex the contender rows carry. - let base58 = Data(hexString: hex)?.toBase58String() ?? hex + // The FFI expects a base58 identity id; convert from the hex the + // contender rows carry. Guard the decode rather than falling back + // to the raw hex string. + guard let base58 = Data(hexString: hex)?.toBase58String() else { + return nil + } return .towardsIdentity(base58) case .abstain: return .abstain