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/mod.rs b/packages/rs-sdk-ffi/src/contested_resource/mod.rs index b1ba67a5042..1fd02d706a9 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/mod.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/mod.rs @@ -1,5 +1,93 @@ // Contested resource modules mod queries; +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 new file mode 100644 index 00000000000..180e56ebcf4 --- /dev/null +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs @@ -0,0 +1,457 @@ +//! 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; + +/// Resource vote choice discriminant, mirroring +/// `ResourceVoteChoice::try_from((i32, Option>))` in rs-dpp: +/// `0` = TowardsIdentity (requires `contender_identity_id`), `1` = Abstain, +/// `2` = Lock. +/// +/// 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. +/// +/// 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 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 +/// `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. +/// * `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, 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(crate::contested_resource::parse_index_value) + .collect::, String>>() + .map_err(|e| invalid(&e))?; + + 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 -------------------------------------------- + // 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 { + 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)", + )); + } + 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)) + } + 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 {}", + 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::{dash_sdk_error_free, 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() { + // 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(), + 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(), + priv_key.as_ptr(), + 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); + } + } + + #[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, + 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(), + priv_key.as_ptr(), + FFINetwork::Testnet, + ); + 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); + } + } + + #[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]; + // 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, + 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(), + priv_key.as_ptr(), + FFINetwork::Testnet, + ); + 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/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..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. /// @@ -50,6 +51,35 @@ 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` 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 ContestedResourceVoteChoiceFFI(0) + case .abstain: return ContestedResourceVoteChoiceFFI(1) + case .lock: return ContestedResourceVoteChoiceFFI(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..0a674a180da 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,207 @@ 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 + } + // `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 + } + 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 + } + + 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 = "" + /// 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 { + 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 { + // 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(choice, proTxHashHex, votingKeyHex) + } + } label: { + if isCasting { + HStack { + ProgressView() + Text("Broadcasting…") + } + } else { + Text("Submit Vote") + } + } + .disabled(isCasting || proTxHashHex.isEmpty || votingKeyHex.isEmpty) + + if let choiceError { + Text(choiceError) + .font(.caption) + .foregroundColor(.orange) + } + } + } + .navigationTitle("Cast Vote") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isCasting) + } + } + } + } + + /// Translate the picker selection into the SDK vote-choice enum. + /// + /// 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. 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 + case .lock: + return .lock + } + } }