Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/rs-sdk-ffi/cbindgen.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
88 changes: 88 additions & 0 deletions packages/rs-sdk-ffi/src/contested_resource/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Value, String> {
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)),
}
Comment on lines +30 to +41

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 0x prefix as type tag is ambiguous for valid DPNS text labels starting with 0x

Replacing the even-length-all-hex heuristic with an explicit 0x prefix fixes the "deadbeef" case, but the prefix itself is still meaningful text: a DPNS contested label like "0xdeadbeef" matches DPNS's alphanumeric label rules and is signable. With the current parser, such a label routed through the Swift castContestedResourceVote path (which forwards the user-visible label string verbatim) is signed as Value::Bytes([0xde, 0xad, 0xbe, 0xef]) instead of Value::Text("0xdeadbeef"), so the masternode broadcasts a vote against a different vote-poll encoding than the one displayed in ContestDetailView. The vote is rejected or misdirected rather than counted for the displayed contest. A structured tag — e.g. { "type": "text"|"bytes", "value": ... } on the FFI surface, or pre-encoding the Swift caller's text labels with an unambiguous wrapper — removes the residual ambiguity on a signing surface. Treat any prefix-based magic on a signing input as a half-fix.

source: ['codex']

}
Comment on lines +29 to +42

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: parse_index_value silently changes query-side index-value semantics

The shared helper now backs not only the new cast_vote write path but also the existing query FFIs (vote_state, resources, voters_for_identity). Before this PR a bare even-length hex string such as "deadbeef" was decoded to Value::Bytes; at HEAD the same input produces Value::Text, with no error and no compatibility shim. Any existing C/Swift caller that intentionally passed unprefixed hex to query a Value::Bytes index will now silently query a different poll and get an empty result. The hardening on the signing path is correct (never auto-encode on a signing surface), but the read path's behavior change deserves either input validation that rejects bare hex on reads with an explanatory error, or an explicit CHANGELOG/migration note plus a deprecation period before flipping query semantics. As written, the only signal an external caller gets is silently-empty result sets.

source: ['codex', 'claude']


#[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());
}
}
36 changes: 12 additions & 24 deletions packages/rs-sdk-ffi/src/contested_resource/queries/resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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 {
Expand All @@ -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::<Result<Vec<Value>, 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 {
Expand All @@ -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::<Result<Vec<Value>, String>>()?
};

Expand Down
24 changes: 7 additions & 17 deletions packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -162,24 +164,12 @@ fn get_contested_resource_vote_state(
let index_values_array: Vec<String> = 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<Value> = 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::<Result<Vec<Value>, String>>()?;

let result_type = match result_type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -178,24 +180,12 @@ fn get_contested_resource_voters_for_identity(
let index_values_array: Vec<String> = 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<Value> = 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::<Result<Vec<Value>, String>>()?;

let vote_poll = ContestedDocumentResourceVotePoll {
Expand Down
Loading
Loading