Skip to content

Commit cd4b52f

Browse files
lklimekclaude
andauthored
fix(contested-names): normalize DPNS label in vote poll construction + pre-flight existence check (#835)
* 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> * docs(contested-names): slim comments and restyle unit tests as given/when/then Address review feedback on #835: the `dpns_vote_poll_index_values` doc comment and the pre-flight check inline comment were over-explanatory; trimmed each to 2-3 lines with concrete examples. The two unit tests now use inline Given/When/Then comments instead of a free-form doc comment — easier to scan and matches project test-style preference. No logic changes; assertions unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(contested-names): remove time-bound retry-chain reference from pre-flight comment The ~70 s retry chain text is platform-version-bound and will age badly once Platform's MasternodeVote picks up `validates_full_state_on_check_tx()` (b44ccff5, v3.1.0-dev.1+) — CheckTx will start rejecting missing polls and the client-visible cost drops. The pre-flight's durable value is fail-fast with an actionable error regardless of server-side timing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(contested-names): pass pre-normalized label to dpns_vote_poll_index_values Bot reviewers on #835 (copilot-pull-request-reviewer, coderabbitai) flagged that convert_to_homograph_safe_chars ran twice per vote: once by the caller to build normalized_label for the pre-flight query, and once inside the helper. Harmless today, but if either call site ever picks up a different normalization the pre-flight and the vote poll would key off different strings — silently reproducing the very bug this PR fixes. Helper now takes a pre-normalized &str and returns it verbatim. The caller's normalized_label is passed through once, used for both the pre-flight query and the vote poll's index_values. A dedicated test pins convert_to_homograph_safe_chars("alice") == "a11ce" so the normalization contract itself stays covered after the move. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb7c162 commit cd4b52f

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

src/backend_task/contested_names/vote_on_dpns_name.rs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,29 @@ 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 `[Value::from("dash"), Value::Text(normalized_label.to_owned())]` for a DPNS vote poll.
25+
///
26+
/// Caller must pre-normalize the label via `convert_to_homograph_safe_chars`
27+
/// (`alice` → `a11ce`); Platform indexes polls under the normalized form.
28+
fn dpns_vote_poll_index_values(normalized_label: &str) -> Vec<Value> {
29+
vec![
30+
Value::from("dash"),
31+
Value::Text(normalized_label.to_owned()),
32+
]
33+
}
34+
2035
impl AppContext {
2136
pub(super) async fn vote_on_dpns_name(
2237
self: &Arc<Self>,
@@ -42,15 +57,44 @@ impl AppContext {
4257
});
4358
};
4459

45-
let index_values = [Value::from("dash"), Value::Text(name.to_owned())];
60+
let normalized_label = convert_to_homograph_safe_chars(name);
61+
let index_values = dpns_vote_poll_index_values(&normalized_label);
4662

4763
let vote_poll = ContestedDocumentResourceVotePoll {
4864
index_name: contested_index.name.clone(),
49-
index_values: index_values.to_vec(),
65+
index_values,
5066
document_type_name: document_type.name().to_string(),
5167
contract_id: data_contract.id(),
5268
};
5369

70+
// Pre-flight: confirm Platform has an open poll for this label before
71+
// broadcasting — fails fast with VotePollNotFound if it doesn't.
72+
let existence_query = VotePollsByDocumentTypeQuery {
73+
contract_id: data_contract.id(),
74+
document_type_name: document_type.name().to_string(),
75+
index_name: contested_index.name.clone(),
76+
start_index_values: vec![Value::from("dash")],
77+
end_index_values: vec![],
78+
// Start exactly at our normalized label (inclusive) — a single
79+
// row is enough to confirm the poll exists.
80+
start_at_value: Some((Value::Text(normalized_label.clone()), true)),
81+
limit: Some(1),
82+
order_ascending: true,
83+
};
84+
85+
let resources = ContestedResource::fetch_many(sdk, existence_query)
86+
.await
87+
.map_err(TaskError::from)?;
88+
let poll_exists = resources
89+
.0
90+
.iter()
91+
.any(|r| r.0.as_str() == Some(normalized_label.as_str()));
92+
if !poll_exists {
93+
return Err(TaskError::VotePollNotFound {
94+
name: name.to_owned(),
95+
});
96+
}
97+
5498
let mut vote_results = vec![];
5599

56100
for qualified_identity in voters.iter() {
@@ -84,3 +128,45 @@ impl AppContext {
84128
Ok(BackendTaskSuccessResult::DPNSVoteResults(vote_results))
85129
}
86130
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::*;
135+
136+
#[test]
137+
fn index_values_uses_the_given_normalized_label() {
138+
// Given: a pre-normalized DPNS label (homographs already substituted).
139+
let normalized = "a11ce";
140+
141+
// When: constructing the vote poll index values.
142+
let values = dpns_vote_poll_index_values(normalized);
143+
144+
// Then: first element is the `"dash"` parent, second is the label as-given.
145+
assert_eq!(values.len(), 2);
146+
assert_eq!(values[0], Value::from("dash"));
147+
assert_eq!(values[1], Value::Text("a11ce".to_owned()));
148+
}
149+
150+
#[test]
151+
fn index_values_do_not_renormalize_the_label() {
152+
// Given: a label that still contains homograph characters.
153+
let not_yet_normalized = "alice";
154+
155+
// When: passing it directly to the helper (violating the contract).
156+
let values = dpns_vote_poll_index_values(not_yet_normalized);
157+
158+
// Then: the helper does NOT renormalize — the raw label is returned as-is.
159+
// (Caller is responsible for normalizing before calling.)
160+
assert_eq!(values[1], Value::Text("alice".to_owned()));
161+
}
162+
163+
#[test]
164+
fn convert_to_homograph_safe_chars_maps_alice_to_a11ce() {
165+
// Given: the canonical DPNS homograph substitutions (i/l → 1, o → 0).
166+
// When: normalizing a label with i/l/o.
167+
let normalized = convert_to_homograph_safe_chars("alice");
168+
169+
// Then: the result matches the constant used by the vote poll tests.
170+
assert_eq!(normalized, "a11ce");
171+
}
172+
}

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)