Skip to content

Commit 801cf07

Browse files
lklimekclaude
andcommitted
fix(contested-names): normalize DPNS label in vote poll construction + pre-flight existence check
`vote_on_dpns_name` was constructing `ContestedDocumentResourceVotePoll` with the raw DPNS label passed in by the caller. Platform indexes the poll under the **normalized** label produced by `convert_to_homograph_safe_chars` (`i`→`1`, `l`→`1`, `o`→`0`, `to_lowercase`). Any label containing those characters — i.e. the common case — produced a `(dash, <raw>)` lookup against an index keyed on `(dash, <normalized>)`, triggering `VotePollNotFoundError` at `PrepareProposal`. Client-side this surfaced as a ~70 s opaque "An unexpected error occurred" timeout from the vote retry chain. Confirmed against drive-abci logs at heights 319048, 319073, and 318950. Changes: * Normalize the label via `convert_to_homograph_safe_chars` before building `index_values`. Extracted as `dpns_vote_poll_index_values`, a pure helper with unit coverage. * Add a pre-flight existence check that queries Platform for the vote poll before broadcasting any state transitions. Missing polls now return immediately instead of burning ~70 s on retries. * New `TaskError::VotePollNotFound { name }` variant with a user-facing message (Everyday User persona, "what happened + what to do"): "The contested name \"{name}\" is not currently open for voting. It may have been resolved or may not exist. Refresh the contested names list and try again." * Unit tests pin the normalization contract (`alice` → `a11ce`, `bar22` → `bar22`) so regressions in the helper fail in CI. Other call-sites surveyed: * `query_dpns_vote_contenders.rs` builds the same poll but is only called with labels already fetched from Platform (normalized), so is left unchanged per scope. * `query_ending_times.rs` only **reads** `index_values` from polls returned by Platform — no construction, no normalization needed. In the DET UI, `VoteOnDPNSNames` is dispatched with `contested_name.normalized_contested_name`, so the user-facing path happens to already supply a normalized string — but the backend must be defensive regardless of caller (CLI, scheduled vote replay, tests), hence the fix at the boundary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb7c162 commit 801cf07

2 files changed

Lines changed: 89 additions & 2 deletions

File tree

src/backend_task/contested_names/vote_on_dpns_name.rs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,35 @@ use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getter
99
use dash_sdk::dpp::identity::accessors::IdentityGettersV0;
1010
use dash_sdk::dpp::platform_value::Value;
1111
use dash_sdk::dpp::platform_value::string_encoding::Encoding;
12+
use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars;
1213
use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice;
1314
use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll;
1415
use dash_sdk::dpp::voting::votes::Vote;
1516
use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote;
1617
use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0;
18+
use dash_sdk::drive::query::vote_polls_by_document_type_query::VotePollsByDocumentTypeQuery;
19+
use dash_sdk::platform::FetchMany;
1720
use dash_sdk::platform::transition::vote::PutVote;
21+
use dash_sdk::query_types::ContestedResource;
1822
use std::sync::Arc;
1923

24+
/// Build the DPNS contested-index values for a vote poll.
25+
///
26+
/// Platform stores the vote poll under the homograph-safe **normalized** label,
27+
/// not the raw user input (e.g. `"alice"` → `"a11ce"`, with `i`→`1`, `l`→`1`,
28+
/// `o`→`0`, and `to_lowercase`). Any caller that constructs a vote poll with
29+
/// the raw label hits `VotePollNotFoundError` at `PrepareProposal`, which
30+
/// surfaces as an opaque ~70 s timeout.
31+
///
32+
/// Pure helper — no `AppContext`/`Sdk` dependency, trivially unit-testable.
33+
/// Returns `[Value::from("dash"), Value::Text(normalized_label)]`.
34+
fn dpns_vote_poll_index_values(name: &str) -> Vec<Value> {
35+
vec![
36+
Value::from("dash"),
37+
Value::Text(convert_to_homograph_safe_chars(name)),
38+
]
39+
}
40+
2041
impl AppContext {
2142
pub(super) async fn vote_on_dpns_name(
2243
self: &Arc<Self>,
@@ -42,15 +63,47 @@ impl AppContext {
4263
});
4364
};
4465

45-
let index_values = [Value::from("dash"), Value::Text(name.to_owned())];
66+
let normalized_label = convert_to_homograph_safe_chars(name);
67+
let index_values = dpns_vote_poll_index_values(name);
4668

4769
let vote_poll = ContestedDocumentResourceVotePoll {
4870
index_name: contested_index.name.clone(),
49-
index_values: index_values.to_vec(),
71+
index_values,
5072
document_type_name: document_type.name().to_string(),
5173
contract_id: data_contract.id(),
5274
};
5375

76+
// Pre-flight existence check. Confirm Platform actually has an open
77+
// vote poll for `(dash, normalized_label)` before broadcasting any
78+
// signed vote state transitions. This short-circuits the ~70 s
79+
// retry chain triggered by `VotePollNotFoundError` at
80+
// `PrepareProposal` when the poll is missing or already resolved.
81+
let existence_query = VotePollsByDocumentTypeQuery {
82+
contract_id: data_contract.id(),
83+
document_type_name: document_type.name().to_string(),
84+
index_name: contested_index.name.clone(),
85+
start_index_values: vec![Value::from("dash")],
86+
end_index_values: vec![],
87+
// Start exactly at our normalized label (inclusive) — a single
88+
// row is enough to confirm the poll exists.
89+
start_at_value: Some((Value::Text(normalized_label.clone()), true)),
90+
limit: Some(1),
91+
order_ascending: true,
92+
};
93+
94+
let resources = ContestedResource::fetch_many(sdk, existence_query)
95+
.await
96+
.map_err(TaskError::from)?;
97+
let poll_exists = resources
98+
.0
99+
.iter()
100+
.any(|r| r.0.as_str() == Some(normalized_label.as_str()));
101+
if !poll_exists {
102+
return Err(TaskError::VotePollNotFound {
103+
name: name.to_owned(),
104+
});
105+
}
106+
54107
let mut vote_results = vec![];
55108

56109
for qualified_identity in voters.iter() {
@@ -84,3 +137,27 @@ impl AppContext {
84137
Ok(BackendTaskSuccessResult::DPNSVoteResults(vote_results))
85138
}
86139
}
140+
141+
#[cfg(test)]
142+
mod tests {
143+
use super::*;
144+
145+
/// Homograph substitutions must apply — Platform indexes the poll under
146+
/// the normalized label, so a raw label produces a `VotePollNotFound`
147+
/// mismatch at `PrepareProposal` time.
148+
#[test]
149+
fn index_values_normalizes_homograph_chars() {
150+
let values = dpns_vote_poll_index_values("alice");
151+
assert_eq!(values.len(), 2);
152+
assert_eq!(values[0], Value::from("dash"));
153+
assert_eq!(values[1], Value::Text("a11ce".to_owned()));
154+
}
155+
156+
/// Labels that contain no homograph characters must round-trip unchanged —
157+
/// except for `to_lowercase`, which is part of the normalization pipeline.
158+
#[test]
159+
fn index_values_preserve_non_homograph_label() {
160+
let values = dpns_vote_poll_index_values("bar22");
161+
assert_eq!(values[1], Value::Text("bar22".to_owned()));
162+
}
163+
}

src/backend_task/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,6 +842,16 @@ pub enum TaskError {
842842
)]
843843
NoVotingIdentity { identity_id: String },
844844

845+
/// No open vote poll was found on Platform for the given DPNS name.
846+
///
847+
/// Surfaced by the pre-flight existence check in `vote_on_dpns_name`,
848+
/// before any state transition is broadcast. Short-circuits a ~70 s
849+
/// retry chain that would otherwise expire with an opaque timeout.
850+
#[error(
851+
"The contested name \"{name}\" is not currently open for voting. It may have been resolved or may not exist. Refresh the contested names list and try again."
852+
)]
853+
VotePollNotFound { name: String },
854+
845855
/// The identity does not have an authentication key required to sign documents.
846856
#[error(
847857
"This identity does not have a key for signing documents. Please add an authentication key."

0 commit comments

Comments
 (0)