diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/VoteCasting.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/VoteCasting.kt index 5ef08fc9b2f..be239c28700 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/VoteCasting.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/VoteCasting.kt @@ -49,7 +49,17 @@ class VoteCasting internal constructor() { * @param indexValues JSON-encodable index values (DPNS: `["dash", * label]`). Text values are taken verbatim; a `"0x"`-prefixed value is * hex-decoded Rust-side. - * @param proTxHash 32-byte masternode pro_tx_hash. + * @param proTxHash 32-byte masternode pro_tx_hash in **wire order** — the + * orientation `Txid` stores, which is what a parsed ProRegTx yields and + * what a wallet holds internally. NOT the byte order of the hex Core + * displays, which is its reverse. + * + * Not interchangeable: Platform identifies masternodes by the opposite + * orientation (`ProTxHash` is declared `#[hash_newtype(forward)]`, + * `Txid` is not), so the Rust side reverses these bytes before deriving + * the voter identity. Passing display order asks Platform for an + * identity that has never existed, and the vote is rejected as having no + * voter identity. * @param votingPrivateKey 32-byte masternode voting private key. * @param networkOrd `Network.ffiValue` (0 Mainnet, 1 Testnet, 2 Devnet, * 3 Regtest). 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 2b61d482b93..fcdd6111674 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 @@ -20,12 +20,35 @@ //! 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)`. +//! * `hash160(pubkey)` — the voting address, which identifies both the voter +//! identity (`create_voter_identifier`) and the key on it. //! -//! 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. +//! The voting [`IdentityPublicKey`] is **built locally, not fetched**. +//! Platform always assigns a voter identity's voting key id 0: +//! `create_voter_identity_v0` passes 0, and a rotation creates a *different* +//! identity — the identifier includes the voting address — whose key is +//! likewise 0. So the key Platform holds is knowable without a round trip, and +//! `SingleKeySigner::can_sign_with` for `ECDSA_HASH160` recomputes the same +//! `hash160(pubkey)` from the private key, so key and signer agree by +//! construction. +//! +//! # Diagnosis is deferred to the failure path +//! +//! Two things do fail, and Platform reports both as the same opaque +//! "Public key 0 doesn't exist": +//! +//! * no voter identity exists for this `(pro_tx_hash, voting address)` pair, +//! or +//! * after a rotation `update_voter_identity_v0` **disabled** the old +//! identity's keys, so key 0 exists but is unusable. +//! +//! Telling those apart needs the identity, but fetching it before every cast +//! would spend a Platform round trip per (node, contest) on runs that +//! overwhelmingly succeed — a bulk vote of 6 nodes across 10 names is 60 +//! fetches. So the fetch happens only after a broadcast has already failed, +//! where the cost is paid on a path that is already lost. A diagnosis replaces +//! the opaque error; an unrelated failure (network, fees, a closed poll) +//! survives unchanged rather than being recast as a key problem. //! //! A regular wallet is **not** a masternode and has no voting key, so a vote //! broadcast from such a wallet reaches a deterministic *authorization* @@ -39,9 +62,13 @@ 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::identifier::MasternodeIdentifiers; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; 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::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::BinaryData; +use dash_sdk::dpp::platform_value::{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; @@ -49,6 +76,7 @@ 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 dash_sdk::platform::Fetch; use simple_signer::SingleKeySigner; use std::ffi::{c_char, CStr}; use zeroize::Zeroizing; @@ -106,7 +134,17 @@ pub enum ContestedResourceVoteChoiceFFI { /// `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. +/// * `voter_pro_tx_hash` — pointer to the masternode's 32-byte pro_tx_hash in +/// **wire order**: the orientation `Txid` stores, which is what a parsed +/// ProRegTx yields (`reg.txid()`) and what a wallet holds internally. This is +/// NOT the byte order of the hex Core displays, which is its reverse. +/// +/// The two are not interchangeable. Platform identifies masternodes by the +/// opposite orientation — `ProTxHash` is declared `#[hash_newtype(forward)]` +/// while `Txid` is not — so this function reverses these bytes before +/// deriving the voter identity and building the transition. Passing display +/// order asks Platform for an identity that has never existed, and the vote +/// is rejected as having no voter identity. /// * `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. @@ -268,9 +306,26 @@ unsafe fn cast_vote_inner( })); // ---- pro_tx_hash --------------------------------------------------------- + // Callers pass WIRE order — the orientation `Txid` stores, which is what a + // parsed ProRegTx yields (`reg.txid()`) and what the iOS wallet holds. + // + // Platform identifies masternodes by the OTHER orientation. `ProTxHash` is + // declared `#[hash_newtype(forward)]` while `Txid` is not, so + // `ProTxHash::to_byte_array()` is display order — the reverse of a `Txid`'s + // bytes for the same transaction. `rpc-json`'s `MasternodeListItem` holds + // both conventions side by side (`pro_tx_hash: ProTxHash`, + // `collateral_hash: Txid`), and drive-abci builds the voter identity from + // `masternode.pro_tx_hash.to_byte_array()`. + // + // Feeding wire order to `create_voter_identifier` therefore asks Platform + // for an identity that has never existed, and the vote is rejected as + // having no voter identity — with the real one sitting under the reversed + // hash. Reverse once, here, and use the corrected value for both the + // identifier and the transition so they cannot drift apart. 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); + pro_tx_hash_arr.reverse(); let pro_tx_hash = Identifier::new(pro_tx_hash_arr); // ---- Derive the masternode voting key + signer -------------------------- @@ -285,9 +340,7 @@ unsafe fn cast_vote_inner( .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 + // public key. `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. @@ -297,6 +350,16 @@ unsafe fn cast_vote_inner( let public_key = PublicKey::from_secret_key(&secp, &secret_key); let voting_address = hash160::Hash::hash(&public_key.serialize()).to_byte_array(); + // ---- Broadcast, then diagnose only on failure --------------------------- + let wrapper = &*(sdk_handle as *const SDKWrapper); + let sdk = &wrapper.sdk; + + // Platform assigns a voter identity's voting key id 0 and nothing else: + // `create_voter_identity_v0` passes 0, and a rotation creates a DIFFERENT + // identity (the identifier includes the voting address) whose key is also + // 0. So the happy path needs no lookup — build the key Platform holds and + // broadcast. `SingleKeySigner::can_sign_with` recomputes the same hash160 + // from the private key, so the key and the signer agree by construction. let masternode_voting_key: IdentityPublicKey = IdentityPublicKeyV0 { id: 0, purpose: Purpose::VOTING, @@ -309,11 +372,7 @@ unsafe fn cast_vote_inner( } .into(); - // ---- Broadcast ---------------------------------------------------------- - let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = &wrapper.sdk; - - wrapper.runtime.block_on(async { + let broadcast = wrapper.runtime.block_on(async { vote.put_to_platform_and_wait_for_response( pro_tx_hash, &masternode_voting_key, @@ -322,16 +381,141 @@ unsafe fn cast_vote_inner( None, ) .await - .map_err(FFIError::from) - })?; + }); + + let Err(broadcast_error) = broadcast else { + return Ok(()); + }; + + // Only a signature failure about the voting key is worth explaining. A + // closed poll, a fee failure or a transport error says nothing about the + // identity, and diagnosing those would let an absent voter identity + // masquerade as their cause — reporting "no voting identity" for a vote + // that actually arrived too late. + if !is_voter_key_failure(&broadcast_error) { + return Err(FFIError::from(broadcast_error)); + } + + // A missing voter identity and a rotated (disabled) key are + // indistinguishable from Platform's side — both surface as + // "Public key 0 doesn't exist". Fetching the identity says which, but only + // here: doing it before every cast would spend a round trip per + // (node, contest) on runs that overwhelmingly succeed. + let voter_identifier = Identifier::create_voter_identifier(&pro_tx_hash_arr, &voting_address); + let diagnosis = wrapper.runtime.block_on(diagnose_vote_failure( + sdk, + &pro_tx_hash, + &voter_identifier, + &voting_address, + )); + + // Still fall back to the original when the identity and key both check out + // — the signature failure was about something else on the key path. + Err(diagnosis.unwrap_or_else(|| FFIError::from(broadcast_error))) +} + +/// Whether a broadcast failure is Platform rejecting the VOTING KEY, as opposed +/// to anything else that can fail a vote. +/// +/// Matched on the typed consensus error rather than its rendered text: the +/// three signature variants below are exactly the ones the identity fetch can +/// explain, and a message match would silently start diagnosing unrelated +/// failures the first time a string changed. +fn is_voter_key_failure(error: &dash_sdk::Error) -> bool { + use dash_sdk::dpp::consensus::signature::SignatureError; + use dash_sdk::dpp::consensus::ConsensusError; + + fn is_key_signature_error(consensus: &ConsensusError) -> bool { + matches!( + consensus, + ConsensusError::SignatureError( + SignatureError::IdentityNotFoundError(_) + | SignatureError::MissingPublicKeyError(_) + | SignatureError::PublicKeyIsDisabledError(_) + ) + ) + } - Ok(()) + match error { + // Rejected at broadcast: the consensus error rides on the response. + dash_sdk::Error::StateTransitionBroadcastError(e) => { + e.cause.as_ref().is_some_and(is_key_signature_error) + } + // Rejected locally / surfaced as a protocol error. + dash_sdk::Error::Protocol(dash_sdk::dpp::ProtocolError::ConsensusError(e)) => { + is_key_signature_error(e) + } + _ => false, + } +} + +/// Explain a failed vote broadcast, or `None` when the voter identity and its +/// voting key are both fine and the failure lies elsewhere. +/// +/// Runs only after a broadcast has already failed, so its cost is paid on the +/// path that is already lost. +async fn diagnose_vote_failure( + sdk: &dash_sdk::Sdk, + pro_tx_hash: &Identifier, + voter_identifier: &Identifier, + voting_address: &[u8; 20], +) -> Option { + // A fetch that itself fails tells us nothing; leave the original error. + let fetched = Identity::fetch(sdk, *voter_identifier).await.ok()?; + + let Some(identity) = fetched else { + return Some(missing_voter_identity(pro_tx_hash, voter_identifier)); + }; + select_voting_key(&identity, voting_address, voter_identifier).err() } fn invalid(message: &str) -> FFIError { FFIError::InvalidParameter(message.to_string()) } +/// Platform holds no voter identity for this `(pro_tx_hash, voting address)`. +fn missing_voter_identity(pro_tx_hash: &Identifier, voter_identifier: &Identifier) -> FFIError { + FFIError::InvalidParameter(format!( + "No voting identity exists on Platform for masternode {} with this voting key \ + (expected voter identity {}). Either the voting key does not match the \ + masternode's registered voting address, or Platform has not created the \ + voter identity yet.", + pro_tx_hash, voter_identifier + )) +} + +/// Pick the voting key the caller's private key can actually sign with. +/// +/// Matches on the key's own data rather than its position. Platform does +/// assign the voting key id 0, so position would usually work — but it +/// silently picks the wrong key on an identity carrying other keys, and it +/// cannot tell a usable key from one `update_voter_identity_v0` disabled +/// during a rotation. `disabled_at` is therefore part of the match, not an +/// afterthought: a disabled key exists and would be selected by id. +fn select_voting_key( + identity: &Identity, + voting_address: &[u8; 20], + voter_identifier: &Identifier, +) -> Result { + identity + .public_keys() + .values() + .find(|key| { + key.purpose() == Purpose::VOTING + && key.key_type() == KeyType::ECDSA_HASH160 + && key.data().as_slice() == voting_address + && key.disabled_at().is_none() + }) + .cloned() + .ok_or_else(|| { + FFIError::InvalidParameter(format!( + "Voter identity {} has no enabled ECDSA_HASH160 voting key matching this \ + private key. The masternode's voting key may have been rotated.", + voter_identifier + )) + }) +} + unsafe fn cstr<'a>(ptr: *const c_char, field: &str) -> Result<&'a str, FFIError> { CStr::from_ptr(ptr) .to_str() @@ -456,4 +640,239 @@ mod tests { crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); } } + + // ---- select_voting_key -------------------------------------------------- + // + // `Identity::fetch` needs a live SDK, so the identity *lookup* stays + // integration-shaped. Key *selection* is the part that decides whether a + // vote can be signed, and it is pure — these cover it directly. + + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::identity::v0::IdentityV0; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::platform_value::BinaryData; + use std::collections::BTreeMap; + + const VOTING_ADDRESS: [u8; 20] = [7u8; 20]; + const OTHER_ADDRESS: [u8; 20] = [9u8; 20]; + + fn voter_id() -> Identifier { + Identifier::new([3u8; 32]) + } + + fn key( + id: u32, + purpose: Purpose, + key_type: KeyType, + data: [u8; 20], + disabled_at: Option, + ) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type, + read_only: false, + data: BinaryData::new(data.to_vec()), + disabled_at, + }) + } + + fn identity_with(keys: Vec) -> Identity { + let mut public_keys = BTreeMap::new(); + for k in keys { + public_keys.insert(k.id(), k); + } + Identity::V0(IdentityV0 { + id: voter_id(), + public_keys, + balance: 0, + revision: 0, + }) + } + + #[test] + fn selects_the_enabled_voting_key_matching_the_private_key() { + let identity = identity_with(vec![key( + 0, + Purpose::VOTING, + KeyType::ECDSA_HASH160, + VOTING_ADDRESS, + None, + )]); + let selected = select_voting_key(&identity, &VOTING_ADDRESS, &voter_id()) + .expect("the matching enabled key should be selected"); + assert_eq!(selected.data().as_slice(), &VOTING_ADDRESS); + } + + #[test] + fn selects_by_data_not_by_position() { + // The real id is not 0 here. Selecting by position would take the + // AUTHENTICATION key and sign with something the signer cannot back. + let identity = identity_with(vec![ + key( + 0, + Purpose::AUTHENTICATION, + KeyType::ECDSA_HASH160, + OTHER_ADDRESS, + None, + ), + key( + 4, + Purpose::VOTING, + KeyType::ECDSA_HASH160, + VOTING_ADDRESS, + None, + ), + ]); + let selected = select_voting_key(&identity, &VOTING_ADDRESS, &voter_id()) + .expect("the voting key should be found at a non-zero id"); + assert_eq!(selected.id(), 4); + assert_eq!(selected.purpose(), Purpose::VOTING); + } + + #[test] + fn rejects_a_disabled_voting_key() { + // What a rotation leaves behind: `update_voter_identity_v0` disables + // the old identity's keys rather than removing them, so the key exists + // and would be picked by id. + let identity = identity_with(vec![key( + 0, + Purpose::VOTING, + KeyType::ECDSA_HASH160, + VOTING_ADDRESS, + Some(1_700_000_000), + )]); + let err = select_voting_key(&identity, &VOTING_ADDRESS, &voter_id()) + .expect_err("a disabled key must not be selected"); + assert!( + format!("{:?}", err).contains("may have been rotated"), + "expected the rotation diagnostic, got: {:?}", + err + ); + } + + #[test] + fn rejects_a_voting_key_for_a_different_address() { + let identity = identity_with(vec![key( + 0, + Purpose::VOTING, + KeyType::ECDSA_HASH160, + OTHER_ADDRESS, + None, + )]); + assert!(select_voting_key(&identity, &VOTING_ADDRESS, &voter_id()).is_err()); + } + + // ---- is_voter_key_failure ----------------------------------------------- + // + // The gate deciding whether a failed broadcast gets a key diagnosis. Its + // absence was a real defect: diagnosis ran on EVERY failure, so a vote that + // arrived after the poll closed, cast by a node with no voter identity, + // was reported as "no voting identity exists" — replacing the true cause + // with a plausible-looking wrong one. + + use dash_sdk::dpp::consensus::signature::{ + BasicECDSAError, IdentityNotFoundError, MissingPublicKeyError, PublicKeyIsDisabledError, + SignatureError, + }; + use dash_sdk::dpp::consensus::ConsensusError; + + fn broadcast_error_with(cause: ConsensusError) -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError( + dash_sdk::error::StateTransitionBroadcastError { + code: 1, + message: "rejected".to_string(), + cause: Some(cause), + }, + ) + } + + #[test] + fn key_failures_are_diagnosable() { + for cause in [ + ConsensusError::SignatureError(SignatureError::MissingPublicKeyError( + MissingPublicKeyError::new(0), + )), + ConsensusError::SignatureError(SignatureError::PublicKeyIsDisabledError( + PublicKeyIsDisabledError::new(0), + )), + ConsensusError::SignatureError(SignatureError::IdentityNotFoundError( + IdentityNotFoundError::new(Identifier::new([3u8; 32])), + )), + ] { + assert!( + is_voter_key_failure(&broadcast_error_with(cause.clone())), + "{cause:?} is exactly what the identity fetch explains" + ); + } + } + + /// The regression this gate exists for: a failure unrelated to the key must + /// keep its own error even though the identity may well be absent. + #[test] + fn unrelated_failures_are_not_diagnosed() { + // A signature failure that is NOT about the key's existence or state. + // The identity fetch cannot explain it, so it must keep its own error — + // this is the discrimination the gate exists for, not merely + // "signature vs not signature". + let other_signature_failure = broadcast_error_with(ConsensusError::SignatureError( + SignatureError::BasicECDSAError(BasicECDSAError::new("bad signature".to_string())), + )); + assert!(!is_voter_key_failure(&other_signature_failure)); + + // A transport failure carries no consensus cause at all. + let transport = dash_sdk::Error::StateTransitionBroadcastError( + dash_sdk::error::StateTransitionBroadcastError { + code: 2, + message: "connection reset".to_string(), + cause: None, + }, + ); + assert!(!is_voter_key_failure(&transport)); + } + + /// The purpose predicate must be load-bearing on its own. + /// + /// The only other AUTHENTICATION fixture also uses a different address, so + /// it is already rejected by the address check — deleting + /// `purpose() == VOTING` would leave every other test passing. This pins it + /// with an AUTHENTICATION key at the CORRECT address and key type, where + /// purpose is the only thing that can reject it. + #[test] + fn rejects_a_matching_address_under_the_wrong_purpose() { + let identity = identity_with(vec![key( + 0, + Purpose::AUTHENTICATION, + KeyType::ECDSA_HASH160, + VOTING_ADDRESS, + None, + )]); + assert!(select_voting_key(&identity, &VOTING_ADDRESS, &voter_id()).is_err()); + } + + #[test] + fn rejects_a_matching_address_under_the_wrong_key_type() { + // Same 20 bytes, but not a key this signer can sign with. + let identity = identity_with(vec![key( + 0, + Purpose::VOTING, + KeyType::BIP13_SCRIPT_HASH, + VOTING_ADDRESS, + None, + )]); + assert!(select_voting_key(&identity, &VOTING_ADDRESS, &voter_id()).is_err()); + } + + #[test] + fn missing_voter_identity_names_both_identifiers() { + let pro_tx = Identifier::new([1u8; 32]); + let msg = format!("{:?}", missing_voter_identity(&pro_tx, &voter_id())); + assert!(msg.contains(&format!("{}", pro_tx))); + assert!(msg.contains(&format!("{}", voter_id()))); + // The diagnostic must read as prose — it crosses the FFI boundary and + // is shown verbatim to users. + assert!(!msg.contains(" "), "message has whitespace runs: {}", msg); + } } diff --git a/packages/rs-sdk/src/platform/transition/vote.rs b/packages/rs-sdk/src/platform/transition/vote.rs index a19a7199a0a..709310913c0 100644 --- a/packages/rs-sdk/src/platform/transition/vote.rs +++ b/packages/rs-sdk/src/platform/transition/vote.rs @@ -19,8 +19,27 @@ use super::waitable::Waitable; #[async_trait::async_trait] /// A trait for putting a vote on platform +/// +/// # `voter_pro_tx_hash` byte order +/// +/// Both methods take the masternode's pro_tx_hash in the orientation +/// [`ProTxHash`](dpp::dashcore::ProTxHash) stores — the same bytes Core's RPC +/// hex shows. This is **NOT** interchangeable with [`Txid`](dpp::dashcore::Txid) +/// bytes for the same transaction: `ProTxHash` is declared +/// `#[hash_newtype(forward)]` and `Txid` is not, so the two are exact reverses, +/// and `rpc-json`'s `MasternodeListItem` carries both conventions side by side +/// (`pro_tx_hash: ProTxHash`, `collateral_hash: Txid`). +/// +/// The order matters because the voter identity is derived from these bytes +/// (see [`get_voting_identity_id`]) exactly as drive-abci derives it from +/// `masternode.pro_tx_hash.to_byte_array()`. A caller holding wire/`Txid` order +/// — which is what `reg.txid()` yields and what a wallet stores internally — +/// must reverse before calling, or the vote addresses an identity that has +/// never existed and Platform rejects it as having no voter identity. pub trait PutVote>: Waitable { /// Puts a vote on platform + /// + /// `voter_pro_tx_hash` must be in `ProTxHash` order — see the trait docs. async fn put_to_platform( &self, voter_pro_tx_hash: Identifier, @@ -30,6 +49,8 @@ pub trait PutVote>: Waitable { settings: Option, ) -> Result<(), Error>; /// Puts a vote on platform and waits for the confirmation proof + /// + /// `voter_pro_tx_hash` must be in `ProTxHash` order — see the trait docs. async fn put_to_platform_and_wait_for_response( &self, voter_pro_tx_hash: Identifier, @@ -135,6 +156,12 @@ impl> PutVote for Vote { } } +/// The voter identity id for `(voter_pro_tx_hash, voting key)`. +/// +/// `voter_pro_tx_hash` is used verbatim, so it must already be in `ProTxHash` +/// order (see [`PutVote`]) — this is the same derivation drive-abci performs in +/// `create_voter_identity_v0`, and passing the reversed `Txid` bytes silently +/// yields an identity that does not exist rather than an error. fn get_voting_identity_id( voter_pro_tx_hash: Identifier, voting_public_key: &IdentityPublicKey, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index d696c79b9c9..9345c9f1199 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -1453,7 +1453,17 @@ extension SDK { /// - indexValues: Index values identifying the contested resource /// (e.g. `["dash", "alice"]`). /// - choice: TowardsIdentity / Abstain / Lock. - /// - proTxHash: The masternode's 32-byte pro_tx_hash. + /// - proTxHash: The masternode's 32-byte pro_tx_hash in **WIRE order** — the + /// orientation `Txid` stores, which is what a parsed ProRegTx yields + /// (`reg.txid()`) and what a wallet holds internally. NOT the byte + /// order of the hex Core displays, which is its reverse. + /// + /// This matters and is not interchangeable: Platform identifies + /// masternodes by the opposite orientation (`ProTxHash` is declared + /// `#[hash_newtype(forward)]`, `Txid` is not), so the Rust side + /// reverses these bytes before deriving the voter identity. Passing + /// display order here asks Platform for an identity that has never + /// existed, and the vote is rejected as having no voter identity. /// - 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.