Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
343a5e4
test: add backend E2E tests for masternode identity features (TC-084 …
lklimek Apr 16, 2026
6aeba71
fix: adapt field name to v1.0-dev (selected_wallet_seed_hash)
lklimek Apr 16, 2026
e16186c
fix: address grumpy-review feedback on MN identity tests
lklimek Apr 16, 2026
d5dec2d
fix: remove unused E2E_MN_PAYOUT_KEY — no test exercises it independe…
lklimek Apr 16, 2026
980670b
refactor: add tracing spans for parallel test execution
lklimek Apr 16, 2026
59227bf
docs: add test guidelines section to backend-e2e README
lklimek Apr 16, 2026
b0bd46f
fix: load .env before reading MN credentials in tests
lklimek Apr 16, 2026
e0bb339
test: add multi-output payment test to prove IS lock bug
lklimek Apr 16, 2026
6fcac99
test: add single-wallet multi-output IS lock bug reproduction
lklimek Apr 16, 2026
4e8fa30
Merge branch 'v1.0-dev' into test/mn-identity-e2e
lklimek Apr 20, 2026
51a6790
chore(deps): bump platform to 2cc4cdcf8 (rust-dashcore 309fac8)
lklimek Apr 20, 2026
6aba82a
fix(spv): reset filter_committed_height on Clear SPV Data
lklimek Apr 20, 2026
2206a24
chore: merge chore/bump-rust-dashcore-309fac8 into test/mn-identity-e2e
lklimek Apr 21, 2026
502054e
test(e2e): fix TC-090 contested name generation and add contest precheck
lklimek Apr 21, 2026
ca46282
fix(backend): use typed TaskError in DPNSVoteResults per-vote result
lklimek Apr 21, 2026
d17cfae
fix(contested-names): normalize DPNS label in vote poll construction …
lklimek Apr 21, 2026
76339e5
test(e2e): re-confirm contest poll exists immediately before voting i…
lklimek Apr 21, 2026
89d5de3
docs(contested-names): slim comments and restyle unit tests as given/…
lklimek Apr 21, 2026
fcc6088
docs(contested-names): remove time-bound retry-chain reference from p…
lklimek Apr 21, 2026
59f0fc5
refactor(contested-names): pass pre-normalized label to dpns_vote_pol…
lklimek Apr 21, 2026
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
139 changes: 96 additions & 43 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ qrcode = "0.14.1"
nix = { version = "0.31.1", features = ["signal"] }
eframe = { version = "0.33.3", features = ["persistence", "wgpu"] }
base64 = "0.22.1"
dash-sdk = { git = "https://github.com/dashpay/platform", rev = "9d799d339f961bed5aa21d3e3e3efe9374b7929c", features = [
dash-sdk = { git = "https://github.com/dashpay/platform", rev = "2cc4cdcf85ce62b437031671336bf622b3aafaa5", features = [
"core_key_wallet",
"core_key_wallet_manager",
"core_bincode",
Expand All @@ -28,7 +28,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "9d799d339f961be
"core_spv",
"shielded",
] }
rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "9d799d339f961bed5aa21d3e3e3efe9374b7929c" }
rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "2cc4cdcf85ce62b437031671336bf622b3aafaa5" }
zip32 = "0.2.0"
grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" }
rayon = "1.8"
Expand Down
2 changes: 1 addition & 1 deletion src/backend_task/contested_names/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ impl AppContext {
platform_results
}
Err(det_err) => {
vec![(name.clone(), *vote_choice, Err(det_err.to_string()))]
vec![(name.clone(), *vote_choice, Err(Arc::new(det_err)))]
}
Ok(_) => {
vec![(name.clone(), *vote_choice, Ok(()))]
Expand Down
92 changes: 89 additions & 3 deletions src/backend_task/contested_names/vote_on_dpns_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,29 @@ use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getter
use dash_sdk::dpp::identity::accessors::IdentityGettersV0;
use dash_sdk::dpp::platform_value::Value;
use dash_sdk::dpp::platform_value::string_encoding::Encoding;
use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars;
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::votes::Vote;
use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote;
use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0;
use dash_sdk::drive::query::vote_polls_by_document_type_query::VotePollsByDocumentTypeQuery;
use dash_sdk::platform::FetchMany;
use dash_sdk::platform::transition::vote::PutVote;
use dash_sdk::query_types::ContestedResource;
use std::sync::Arc;

/// Build `[Value::from("dash"), Value::Text(normalized_label.to_owned())]` for a DPNS vote poll.
///
/// Caller must pre-normalize the label via `convert_to_homograph_safe_chars`
/// (`alice` → `a11ce`); Platform indexes polls under the normalized form.
fn dpns_vote_poll_index_values(normalized_label: &str) -> Vec<Value> {
vec![
Value::from("dash"),
Value::Text(normalized_label.to_owned()),
]
}

impl AppContext {
pub(super) async fn vote_on_dpns_name(
self: &Arc<Self>,
Expand All @@ -42,15 +57,44 @@ impl AppContext {
});
};

let index_values = [Value::from("dash"), Value::Text(name.to_owned())];
let normalized_label = convert_to_homograph_safe_chars(name);
let index_values = dpns_vote_poll_index_values(&normalized_label);

let vote_poll = ContestedDocumentResourceVotePoll {
index_name: contested_index.name.clone(),
index_values: index_values.to_vec(),
index_values,
document_type_name: document_type.name().to_string(),
contract_id: data_contract.id(),
};

// Pre-flight: confirm Platform has an open poll for this label before
// broadcasting — fails fast with VotePollNotFound if it doesn't.
let existence_query = VotePollsByDocumentTypeQuery {
contract_id: data_contract.id(),
document_type_name: document_type.name().to_string(),
index_name: contested_index.name.clone(),
start_index_values: vec![Value::from("dash")],
end_index_values: vec![],
// Start exactly at our normalized label (inclusive) — a single
// row is enough to confirm the poll exists.
start_at_value: Some((Value::Text(normalized_label.clone()), true)),
limit: Some(1),
order_ascending: true,
};

let resources = ContestedResource::fetch_many(sdk, existence_query)
.await
.map_err(TaskError::from)?;
let poll_exists = resources
.0
.iter()
.any(|r| r.0.as_str() == Some(normalized_label.as_str()));
if !poll_exists {
return Err(TaskError::VotePollNotFound {
name: name.to_owned(),
});
}

let mut vote_results = vec![];

for qualified_identity in voters.iter() {
Expand All @@ -71,7 +115,7 @@ impl AppContext {
)
.await
.map(|_| ())
.map_err(|e| TaskError::from(e).to_string());
.map_err(|e| Arc::new(TaskError::from(e)));

vote_results.push((name.to_owned(), vote_choice, result));
} else {
Expand All @@ -84,3 +128,45 @@ impl AppContext {
Ok(BackendTaskSuccessResult::DPNSVoteResults(vote_results))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn index_values_uses_the_given_normalized_label() {
// Given: a pre-normalized DPNS label (homographs already substituted).
let normalized = "a11ce";

// When: constructing the vote poll index values.
let values = dpns_vote_poll_index_values(normalized);

// Then: first element is the `"dash"` parent, second is the label as-given.
assert_eq!(values.len(), 2);
assert_eq!(values[0], Value::from("dash"));
assert_eq!(values[1], Value::Text("a11ce".to_owned()));
}

#[test]
fn index_values_do_not_renormalize_the_label() {
// Given: a label that still contains homograph characters.
let not_yet_normalized = "alice";

// When: passing it directly to the helper (violating the contract).
let values = dpns_vote_poll_index_values(not_yet_normalized);

// Then: the helper does NOT renormalize — the raw label is returned as-is.
// (Caller is responsible for normalizing before calling.)
assert_eq!(values[1], Value::Text("alice".to_owned()));
}

#[test]
fn convert_to_homograph_safe_chars_maps_alice_to_a11ce() {
// Given: the canonical DPNS homograph substitutions (i/l → 1, o → 0).
// When: normalizing a label with i/l/o.
let normalized = convert_to_homograph_safe_chars("alice");

// Then: the result matches the constant used by the vote poll tests.
assert_eq!(normalized, "a11ce");
}
}
10 changes: 10 additions & 0 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,16 @@ pub enum TaskError {
)]
NoVotingIdentity { identity_id: String },

/// No open vote poll was found on Platform for the given DPNS name.
///
/// Surfaced by the pre-flight existence check in `vote_on_dpns_name`,
/// before any state transition is broadcast. Short-circuits a ~70 s
/// retry chain that would otherwise expire with an opaque timeout.
#[error(
"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."
)]
VotePollNotFound { name: String },

/// The identity does not have an authentication key required to sign documents.
#[error(
"This identity does not have a key for signing documents. Please add an authentication key."
Expand Down
5 changes: 4 additions & 1 deletion src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,10 @@ pub enum BackendTaskSuccessResult {
ToppedUpIdentity(QualifiedIdentity, FeeResult),
#[allow(dead_code)] // May be used for reporting successful votes
SuccessfulVotes(Vec<Vote>),
DPNSVoteResults(Vec<(String, ResourceVoteChoice, Result<(), String>)>),
// Per-vote errors are wrapped in `Arc` because `BackendTaskSuccessResult`
// derives `Clone` for UI fan-out (see `dashpay_screen::display_task_result`),
// while `TaskError` intentionally does not implement `Clone`.
DPNSVoteResults(Vec<(String, ResourceVoteChoice, Result<(), Arc<TaskError>>)>),
CastScheduledVote(ScheduledDPNSVote),
FetchedContract(DataContract),
FetchedContractWithTokenPosition(
Expand Down
6 changes: 4 additions & 2 deletions src/model/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::database::{Database, WalletError};
use crate::model::secret::Secret;
use dash_sdk::dpp::ProtocolError;
use dash_sdk::dpp::address_funds::{AddressWitness, PlatformAddress};
use dash_sdk::dpp::async_trait::async_trait;
use dash_sdk::dpp::identity::signer::Signer;
use dash_sdk::dpp::key_wallet::account::AccountType;
use dash_sdk::dpp::key_wallet::bip32::{
Expand Down Expand Up @@ -2644,6 +2645,7 @@ impl WalletAddressProvider {
}
}

#[async_trait]
impl AddressProvider for WalletAddressProvider {
fn gap_limit(&self) -> AddressIndex {
self.gap_limit
Expand All @@ -2657,7 +2659,7 @@ impl AddressProvider for WalletAddressProvider {
.collect()
}

fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], funds: AddressFunds) {
async fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], funds: AddressFunds) {
self.resolved.insert(index);

// Log what the SDK is returning
Expand Down Expand Up @@ -2698,7 +2700,7 @@ impl AddressProvider for WalletAddressProvider {
}
}

fn on_address_absent(&mut self, index: AddressIndex, _key: &[u8]) {
async fn on_address_absent(&mut self, index: AddressIndex, _key: &[u8]) {
self.resolved.insert(index);
}

Expand Down
13 changes: 9 additions & 4 deletions src/spv/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,14 +813,19 @@ impl SpvManager {
wallet_map.clear();
}

// Reset the in-memory WalletManager's synced_height so the next SPV session
// scans filters from genesis instead of the stale height from the previous run.
// Reset the in-memory WalletManager's filter_committed_height so the next
// SPV session scans filters from genesis instead of the stale height from the
// previous run. We reset filter_committed_height (not synced_height) because at
// rust-dashcore 309fac8 these became independent fields — FiltersManager::new()
// reads filter_committed_height() for its "already synced" guard.
match self.wallet.try_write() {
Ok(mut wm) => {
wm.update_synced_height(0);
wm.update_filter_committed_height(0);
}
Err(_) => {
tracing::warn!("Failed to reset WalletManager synced_height during SPV data clear");
tracing::warn!(
"Failed to reset WalletManager filter_committed_height during SPV data clear"
);
}
}

Expand Down
47 changes: 47 additions & 0 deletions src/spv/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::spv::SpvStatus;
use crate::spv::manager::SpvManager;
use crate::utils::tasks::TaskManager;
use dash_sdk::dpp::dashcore::Network;
use dash_sdk::dpp::key_wallet_manager::WalletInterface;
use std::sync::{Arc, RwLock};
use tokio::time::{Duration, timeout};

Expand Down Expand Up @@ -637,3 +638,49 @@ async fn test_live_testnet_sync_and_shutdown() {

let _ = task_manager.shutdown();
}

/// Regression test: clear_data_dir must reset filter_committed_height (not just synced_height).
///
/// At rust-dashcore 309fac8, WalletManager gained an independent filter_committed_height
/// field. FiltersManager::new() reads filter_committed_height() for its "already synced"
/// guard — the field is no longer derived from synced_height. Calling update_synced_height(0)
/// alone leaves filter_committed_height stale and causes the next SPV session to declare
/// itself "already synced" and skip the rescan, producing zero balance after a Clear SPV Data.
///
/// Given a manager with a non-zero filter_committed_height,
/// When clear_data_dir() is called,
/// Then filter_committed_height is reset to 0.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_clear_data_dir_resets_filter_committed_height() {
let (manager, _tm, _tmp_dir) = create_test_manager();

// Pre-seed a non-zero filter_committed_height to simulate a previous session.
const PRESEED_HEIGHT: u32 = 5_000;
let wallet_arc = manager.wallet();
{
let mut wm = wallet_arc.write().await;
wm.update_filter_committed_height(PRESEED_HEIGHT);
}

// Verify pre-condition.
{
let wm = wallet_arc.read().await;
assert_eq!(
wm.filter_committed_height(),
PRESEED_HEIGHT,
"pre-condition: filter_committed_height should be PRESEED_HEIGHT"
);
}

manager
.clear_data_dir()
.expect("clear_data_dir should succeed");

// After clearing, filter_committed_height must be 0.
let wm = wallet_arc.read().await;
assert_eq!(
wm.filter_committed_height(),
0,
"clear_data_dir must reset filter_committed_height to 0"
);
}
2 changes: 1 addition & 1 deletion src/ui/dpns/dpns_contested_names_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1853,7 +1853,7 @@ impl ScreenLike for DPNSScreen {
BackendTaskSuccessResult::DPNSVoteResults(results) => {
let errors: Vec<String> = results
.iter()
.filter_map(|(_, _, r)| r.as_ref().err().cloned())
.filter_map(|(_, _, r)| r.as_ref().err().map(|e| e.to_string()))
.collect();
let successes: Vec<String> = results
.iter()
Expand Down
Loading