From 331c7456dadc37fbe9fab6c186fb7a3d837a0352 Mon Sep 17 00:00:00 2001 From: romchornyi Date: Mon, 2 Nov 2026 18:07:27 +0200 Subject: [PATCH 1/2] feat(swift-sdk): expose when each DPNS contender submitted its request Each contender of a contested DPNS name carries its serialized domain document, and the FFI already decoded it for the requested label. It now also returns the document's $createdAt: DashSDKContender gains created_at (ms) and has_created_at at its end, the vote-state JSON gains an optional "created_at" key, and DPNSContender gains createdAt: Date?. The two list queries share one contest_info_to_ffi, and both readers use one contender_document_fields decode. Co-Authored-By: Claude Opus 5.5 --- .../contested_resource/contender_document.rs | 307 ++++++++++++++++ .../rs-sdk-ffi/src/contested_resource/mod.rs | 1 + .../contested_resource/queries/vote_state.rs | 43 +-- .../rs-sdk-ffi/src/dpns/queries/contested.rs | 340 +++++++++--------- packages/rs-sdk-ffi/src/types.rs | 9 + .../FFI/PlatformQueryExtensions.swift | 12 + .../SwiftDashSDK/Voting/DPNSContest.swift | 18 +- .../Voting/SDK+DPNSContests.swift | 65 +++- .../DPNSContestDecoderTests.swift | 57 +++ 9 files changed, 645 insertions(+), 207 deletions(-) create mode 100644 packages/rs-sdk-ffi/src/contested_resource/contender_document.rs diff --git a/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs b/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs new file mode 100644 index 00000000000..96ef14e5bdb --- /dev/null +++ b/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs @@ -0,0 +1,307 @@ +//! What the FFI reads out of a contender's serialized document. +//! +//! Every contender of a contested name carries the document it submitted +//! (for DPNS, its `domain` document). The list queries and the vote-state +//! query both decode it to give callers the requester's own spelling and the +//! time the request was made; this module is the one place that decode lives. + +use crate::types::DashSDKContender; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocument; +use std::ffi::CString; +use std::os::raw::c_char; + +/// Fields decoded from a contender's document. Both are `None` when the +/// document could not be decoded. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct ContenderDocumentFields { + /// The label exactly as the contender typed it ("pizza"), where the + /// contest is keyed by its normalized form ("p1zza"). `None` when empty. + pub label: Option, + /// The document's `$createdAt`, in milliseconds since the Unix epoch: + /// when this request was submitted. `None` for 0, which is not a time a + /// document can have been created at, so every binding reads it the same + /// way. + pub created_at_ms: Option, +} + +impl ContenderDocumentFields { + /// The C contender the list queries hand out. Takes ownership of + /// `identity_id`; the label is allocated here. Both are released by + /// `free_contender_fields`, which every contender destructor goes through. + pub(crate) fn into_ffi_contender( + self, + identity_id: *mut c_char, + vote_count: u32, + ) -> DashSDKContender { + DashSDKContender { + identity_id, + vote_count, + label: self + .label + .and_then(|label| CString::new(label).ok()) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()), + created_at: self.created_at_ms.unwrap_or(0), + has_created_at: self.created_at_ms.is_some(), + } + } + + /// The optional members of a vote-state contender object, each with its + /// leading comma: `,"label":"pizza","created_at":1760140800000`. A field + /// that did not decode is left out, never written as null. + pub(crate) fn json_members(&self) -> String { + let label = self + .label + .as_ref() + .and_then(|label| serde_json::to_string(label).ok()) + .map(|label| format!(r#","label":{}"#, label)) + .unwrap_or_default(); + let created_at = self + .created_at_ms + .map(|created_at| format!(r#","created_at":{}"#, created_at)) + .unwrap_or_default(); + label + &created_at + } +} + +/// Decode `contender`'s document against `document_type` and read its fields. +/// +/// Best-effort: a missing document type, a contender without a serialized +/// document, or a document that does not decode gives empty fields rather than +/// an error, so one bad contender never fails the whole query. +pub(crate) fn contender_document_fields( + contender: &ContenderWithSerializedDocument, + document_type: Option, + platform_version: &PlatformVersion, +) -> ContenderDocumentFields { + let Some(document_type) = document_type else { + return ContenderDocumentFields::default(); + }; + let Ok(decoded) = contender.try_to_contender(document_type, platform_version) else { + return ContenderDocumentFields::default(); + }; + document_fields(decoded.document().as_ref()) +} + +fn document_fields(document: Option<&Document>) -> ContenderDocumentFields { + let Some(document) = document else { + return ContenderDocumentFields::default(); + }; + ContenderDocumentFields { + label: document + .get("label") + .and_then(|value| value.as_str()) + .filter(|label| !label.is_empty()) + .map(str::to_string), + created_at_ms: document.created_at().filter(|created_at| *created_at > 0), + } +} + +/// Real DPNS `domain` documents for tests, serialized the way Platform hands +/// contenders back. +#[cfg(test)] +pub(crate) mod test_support { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::document::{Document, DocumentV0, DocumentV0Getters}; + use dash_sdk::dpp::identifier::Identifier; + use dash_sdk::dpp::platform_value::Value; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::dpp::voting::contender_structs::{ + Contender, ContenderV0, ContenderWithSerializedDocument, + }; + use std::collections::BTreeMap; + + pub(crate) const CREATED_AT_MS: u64 = 1_760_140_800_000; + + pub(crate) fn domain_document(created_at: Option) -> Document { + requester_document(7, "pizza", created_at) + } + + /// A `domain` document for `label`, owned by the identity `[owner; 32]`. + pub(crate) fn requester_document(owner: u8, label: &str, created_at: Option) -> Document { + let owner_id = Identifier::from([owner; 32]); + let properties = BTreeMap::from([ + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ("label".to_string(), Value::Text(label.to_string())), + ( + "normalizedLabel".to_string(), + Value::Text("p1zza".to_string()), + ), + ("preorderSalt".to_string(), Value::Bytes32([3u8; 32])), + ( + "records".to_string(), + Value::Map(vec![( + Value::Text("identity".to_string()), + Value::Identifier(owner_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]); + Document::V0(DocumentV0 { + id: Identifier::from([owner.wrapping_add(100); 32]), + owner_id, + properties, + revision: Some(1), + created_at, + updated_at: created_at, + transferred_at: created_at, + ..Default::default() + }) + } + + /// Serialize `document` the way Platform hands contenders back. + pub(crate) fn serialized_contender(document: Document) -> ContenderWithSerializedDocument { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS system contract"); + let document_type = contract.document_type_for_name("domain").expect("domain"); + Contender::V0(ContenderV0 { + identity_id: document.owner_id(), + document: Some(document), + vote_tally: Some(3), + }) + .try_into_contender_with_serialized_document(document_type, &contract, platform_version) + .expect("serialize contender") + } +} + +#[cfg(test)] +mod tests { + use super::test_support::{domain_document, serialized_contender, CREATED_AT_MS}; + use super::*; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identifier::Identifier; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocumentV0; + use std::ffi::CStr; + + fn fields(contender: &ContenderWithSerializedDocument) -> ContenderDocumentFields { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS system contract"); + let document_type = contract.document_type_for_name("domain").ok(); + contender_document_fields(contender, document_type, platform_version) + } + + #[test] + fn should_read_label_and_created_at() { + let contender = serialized_contender(domain_document(Some(CREATED_AT_MS))); + assert_eq!( + fields(&contender), + ContenderDocumentFields { + label: Some("pizza".to_string()), + created_at_ms: Some(CREATED_AT_MS), + } + ); + } + + #[test] + fn should_keep_the_label_without_created_at() { + assert_eq!( + document_fields(Some(&domain_document(None))), + ContenderDocumentFields { + label: Some("pizza".to_string()), + created_at_ms: None, + } + ); + } + + #[test] + fn should_treat_a_zero_created_at_as_absent() { + assert_eq!( + document_fields(Some(&domain_document(Some(0)))).created_at_ms, + None + ); + } + + #[test] + fn should_give_no_fields_for_an_undecodable_document() { + let contender = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id: Identifier::from([7u8; 32]), + serialized_document: Some(vec![0xde, 0xad, 0xbe, 0xef]), + vote_tally: Some(1), + }); + assert_eq!(fields(&contender), ContenderDocumentFields::default()); + } + + #[test] + fn should_give_no_fields_without_a_document() { + let contender = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id: Identifier::from([7u8; 32]), + serialized_document: None, + vote_tally: Some(1), + }); + assert_eq!(fields(&contender), ContenderDocumentFields::default()); + } + + #[test] + fn should_fill_the_ffi_contender_from_decoded_fields() { + let fields = ContenderDocumentFields { + label: Some("pizza".to_string()), + created_at_ms: Some(CREATED_AT_MS), + }; + let id = CString::new("Ab1").unwrap().into_raw(); + let contender = fields.into_ffi_contender(id, 4); + unsafe { + assert_eq!(contender.vote_count, 4); + assert!(contender.has_created_at); + assert_eq!(contender.created_at, CREATED_AT_MS); + assert_eq!(CStr::from_ptr(contender.label).to_str().unwrap(), "pizza"); + drop(CString::from_raw(contender.identity_id)); + drop(CString::from_raw(contender.label)); + } + } + + #[test] + fn should_flag_a_missing_date_in_the_ffi_contender() { + let id = CString::new("Ab1").unwrap().into_raw(); + let contender = ContenderDocumentFields::default().into_ffi_contender(id, 0); + assert!(!contender.has_created_at); + assert_eq!(contender.created_at, 0); + assert!(contender.label.is_null()); + unsafe { drop(CString::from_raw(contender.identity_id)) }; + } + + #[test] + fn should_write_label_and_created_at_json_members() { + let fields = ContenderDocumentFields { + label: Some("pi\"zza".to_string()), + created_at_ms: Some(CREATED_AT_MS), + }; + assert_eq!( + fields.json_members(), + format!(r#","label":"pi\"zza","created_at":{}"#, CREATED_AT_MS) + ); + } + + #[test] + fn should_leave_out_json_members_that_did_not_decode() { + assert_eq!(ContenderDocumentFields::default().json_members(), ""); + } + + #[test] + fn should_give_no_fields_without_a_document_type() { + let contender = serialized_contender(domain_document(Some(CREATED_AT_MS))); + assert_eq!( + contender_document_fields(&contender, None, PlatformVersion::latest()), + ContenderDocumentFields::default() + ); + } +} diff --git a/packages/rs-sdk-ffi/src/contested_resource/mod.rs b/packages/rs-sdk-ffi/src/contested_resource/mod.rs index 1fd02d706a9..ea83d567aee 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/mod.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/mod.rs @@ -1,4 +1,5 @@ // Contested resource modules +pub(crate) mod contender_document; mod queries; mod transitions; diff --git a/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs b/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs index 1dacd279511..f5cb39bafe8 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs @@ -1,7 +1,7 @@ +use crate::contested_resource::contender_document::contender_document_fields; use crate::types::SDKHandle; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocument; use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; @@ -244,12 +244,19 @@ fn get_contested_resource_vote_state( // a UI can only show the normalized form, which reads as a // typo to the person who submitted it. // + // The same decode gives `created_at`, the document's + // `$createdAt` in milliseconds: when the request was made. + // // Best-effort: the contract fetch or a single decode - // failing must not fail the whole query, so `label` is - // simply absent for rows that could not be decoded and the - // caller falls back to the normalized value. + // failing must not fail the whole query. `label` and + // `created_at` are then simply absent for that row: the + // caller shows the normalized value instead of a label, and + // has no date for the request. let contract: Option = DataContract::fetch(&sdk, contract_id).await.ok().flatten(); + let document_type = contract.as_ref().and_then(|contract| { + contract.document_type_for_name(document_type_name_str).ok() + }); let contenders_json: Vec = contenders.contenders .iter() @@ -261,27 +268,11 @@ fn get_contested_resource_vote_state( r#""document":null"#.to_string() }; - let label_json = contract - .as_ref() - .and_then(|contract| { - let doc_type = contract - .document_type_for_name(document_type_name_str) - .ok()?; - let decoded = contender - .try_to_contender(doc_type, sdk.version()) - .ok()?; - let label = decoded - .document() - .as_ref()? - .get("label")? - .as_str()? - .to_string(); - Some(format!( - r#","label":{}"#, - serde_json::to_string(&label).ok()? - )) - }) - .unwrap_or_default(); + let fields = contender_document_fields( + contender, + document_type, + sdk.version(), + ); let vote_count = contender.vote_tally().unwrap_or(0); @@ -290,7 +281,7 @@ fn get_contested_resource_vote_state( bs58::encode(id.as_bytes()).into_string(), vote_count, document_json, - label_json + fields.json_members() ) }) .collect(); diff --git a/packages/rs-sdk-ffi/src/dpns/queries/contested.rs b/packages/rs-sdk-ffi/src/dpns/queries/contested.rs index 128167943d7..a99be96ad37 100644 --- a/packages/rs-sdk-ffi/src/dpns/queries/contested.rs +++ b/packages/rs-sdk-ffi/src/dpns/queries/contested.rs @@ -1,20 +1,23 @@ //! FFI bindings for contested DPNS username queries use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::time::Duration; +use crate::contested_resource::contender_document::contender_document_fields; use crate::sdk::SDKWrapper; use crate::types::{ DashSDKContender, DashSDKContestInfo, DashSDKContestedName, DashSDKContestedNamesList, DashSDKNameTimestamp, DashSDKNameTimestampList, SDKHandle, }; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::identifier::Identifier; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::query_types::Contenders; use serde_json::json; // Still used by other functions /// A list query fans out to the vote state of every current contest. Keep one @@ -514,87 +517,12 @@ pub unsafe extern "C" fn dash_sdk_dpns_get_non_resolved_contests_for_identity( Err(_) => continue, }; - // Convert contenders - let contender_count = contest_info.contenders.contenders.len(); - let mut contenders = Vec::with_capacity(contender_count); - - for (contender_id, votes) in contest_info.contenders.contenders { - let id_str = contender_id.to_string(Encoding::Base58); - let c_id = match CString::new(id_str) { - Ok(s) => s.into_raw(), - Err(_) => continue, - }; - - // Extract actual vote tally from ContenderWithSerializedDocument - let vote_count = votes.vote_tally().unwrap_or(0); - - // The requester's own spelling ("pizza"), which the - // normalized contest name ("p1zza") does not preserve. - // Null when the document can't be decoded — callers fall - // back to the normalized name rather than guessing. - let c_label = dpns_domain_type - .as_ref() - .and_then(|doc_type| { - let decoded = votes - .try_to_contender(doc_type.as_ref(), platform_version) - .ok()?; - let label = decoded.document().as_ref()?.get("label")?.as_str()?; - CString::new(label).ok().map(|s| s.into_raw()) - }) - .unwrap_or(std::ptr::null_mut()); - - contenders.push(DashSDKContender { - identity_id: c_id, - vote_count, - label: c_label, - }); - } - - // Ordered, de-duplicated requested labels. Derived here so the - // display policy lives in one place rather than being - // re-implemented by each language binding. - let mut requested_labels: Vec<*mut c_char> = Vec::new(); - let mut seen_labels: Vec = Vec::new(); - for contender in &contenders { - if contender.label.is_null() { - continue; - } - let label = CStr::from_ptr(contender.label) - .to_string_lossy() - .into_owned(); - if label.is_empty() || seen_labels.contains(&label) { - continue; - } - if let Ok(c_label) = CString::new(label.clone()) { - seen_labels.push(label); - requested_labels.push(c_label.into_raw()); - } - } - let requested_label_count = requested_labels.len(); - let requested_labels_ptr = if requested_labels.is_empty() { - std::ptr::null_mut() - } else { - Box::into_raw(requested_labels.into_boxed_slice()) as *mut *mut c_char - }; - - let contender_count = contenders.len(); - let contenders_ptr = if contenders.is_empty() { - std::ptr::null_mut() - } else { - let boxed = contenders.into_boxed_slice(); - Box::into_raw(boxed) as *mut DashSDKContender - }; - - let contest_info_c = DashSDKContestInfo { - contenders: contenders_ptr, - contender_count, - requested_labels: requested_labels_ptr, - requested_label_count, - abstain_votes: contest_info.contenders.abstain_vote_tally.unwrap_or(0), - lock_votes: contest_info.contenders.lock_vote_tally.unwrap_or(0), - end_time: contest_info.end_time, - has_winner: contest_info.contenders.winner.is_some(), - }; + let contest_info_c = contest_info_to_ffi( + contest_info.contenders, + contest_info.end_time, + dpns_domain_type.as_ref().map(|doc_type| doc_type.as_ref()), + platform_version, + ); names.push(DashSDKContestedName { name: c_name, @@ -676,87 +604,12 @@ pub unsafe extern "C" fn dash_sdk_dpns_get_contested_non_resolved_usernames( Err(_) => continue, }; - // Convert contenders - let contender_count = contest_info.contenders.contenders.len(); - let mut contenders = Vec::with_capacity(contender_count); - - for (contender_id, votes) in contest_info.contenders.contenders { - let id_str = contender_id.to_string(Encoding::Base58); - let c_id = match CString::new(id_str) { - Ok(s) => s.into_raw(), - Err(_) => continue, - }; - - // Extract actual vote tally from ContenderWithSerializedDocument - let vote_count = votes.vote_tally().unwrap_or(0); - - // The requester's own spelling ("pizza"), which the - // normalized contest name ("p1zza") does not preserve. - // Null when the document can't be decoded — callers fall - // back to the normalized name rather than guessing. - let c_label = dpns_domain_type - .as_ref() - .and_then(|doc_type| { - let decoded = votes - .try_to_contender(doc_type.as_ref(), platform_version) - .ok()?; - let label = decoded.document().as_ref()?.get("label")?.as_str()?; - CString::new(label).ok().map(|s| s.into_raw()) - }) - .unwrap_or(std::ptr::null_mut()); - - contenders.push(DashSDKContender { - identity_id: c_id, - vote_count, - label: c_label, - }); - } - - // Ordered, de-duplicated requested labels. Derived here so the - // display policy lives in one place rather than being - // re-implemented by each language binding. - let mut requested_labels: Vec<*mut c_char> = Vec::new(); - let mut seen_labels: Vec = Vec::new(); - for contender in &contenders { - if contender.label.is_null() { - continue; - } - let label = CStr::from_ptr(contender.label) - .to_string_lossy() - .into_owned(); - if label.is_empty() || seen_labels.contains(&label) { - continue; - } - if let Ok(c_label) = CString::new(label.clone()) { - seen_labels.push(label); - requested_labels.push(c_label.into_raw()); - } - } - let requested_label_count = requested_labels.len(); - let requested_labels_ptr = if requested_labels.is_empty() { - std::ptr::null_mut() - } else { - Box::into_raw(requested_labels.into_boxed_slice()) as *mut *mut c_char - }; - - let contender_count = contenders.len(); - let contenders_ptr = if contenders.is_empty() { - std::ptr::null_mut() - } else { - let boxed = contenders.into_boxed_slice(); - Box::into_raw(boxed) as *mut DashSDKContender - }; - - let contest_info_c = DashSDKContestInfo { - contenders: contenders_ptr, - contender_count, - requested_labels: requested_labels_ptr, - requested_label_count, - abstain_votes: contest_info.contenders.abstain_vote_tally.unwrap_or(0), - lock_votes: contest_info.contenders.lock_vote_tally.unwrap_or(0), - end_time: contest_info.end_time, - has_winner: contest_info.contenders.winner.is_some(), - }; + let contest_info_c = contest_info_to_ffi( + contest_info.contenders, + contest_info.end_time, + dpns_domain_type.as_ref().map(|doc_type| doc_type.as_ref()), + platform_version, + ); names.push(DashSDKContestedName { name: c_name, @@ -782,3 +635,164 @@ pub unsafe extern "C" fn dash_sdk_dpns_get_contested_non_resolved_usernames( Err(_) => std::ptr::null_mut(), } } + +/// One contest in its C shape, shared by both list queries. +/// +/// Each contender's document is decoded once, for the requester's own +/// spelling and the time of the request. The ordered, de-duplicated requested +/// labels are derived here so the display policy lives in one place rather +/// than being re-implemented by each language binding. A contender whose id +/// cannot be represented as a C string is skipped. +fn contest_info_to_ffi( + contenders: Contenders, + end_time: u64, + dpns_domain_type: Option, + platform_version: &PlatformVersion, +) -> DashSDKContestInfo { + let mut ffi_contenders = Vec::with_capacity(contenders.contenders.len()); + let mut requested_labels: Vec<*mut c_char> = Vec::new(); + let mut seen_labels: Vec = Vec::new(); + + for (contender_id, votes) in contenders.contenders { + let Ok(c_id) = CString::new(contender_id.to_string(Encoding::Base58)) else { + continue; + }; + let vote_count = votes.vote_tally().unwrap_or(0); + let fields = contender_document_fields(&votes, dpns_domain_type, platform_version); + + if let Some(label) = fields.label.as_ref() { + if !seen_labels.contains(label) { + if let Ok(c_label) = CString::new(label.as_str()) { + seen_labels.push(label.clone()); + requested_labels.push(c_label.into_raw()); + } + } + } + + ffi_contenders.push(fields.into_ffi_contender(c_id.into_raw(), vote_count)); + } + + let requested_label_count = requested_labels.len(); + let requested_labels_ptr = if requested_labels.is_empty() { + std::ptr::null_mut() + } else { + Box::into_raw(requested_labels.into_boxed_slice()) as *mut *mut c_char + }; + + let contender_count = ffi_contenders.len(); + let contenders_ptr = if ffi_contenders.is_empty() { + std::ptr::null_mut() + } else { + Box::into_raw(ffi_contenders.into_boxed_slice()) as *mut DashSDKContender + }; + + DashSDKContestInfo { + contenders: contenders_ptr, + contender_count, + requested_labels: requested_labels_ptr, + requested_label_count, + abstain_votes: contenders.abstain_vote_tally.unwrap_or(0), + lock_votes: contenders.lock_vote_tally.unwrap_or(0), + end_time, + has_winner: contenders.winner.is_some(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::contested_resource::contender_document::test_support::{ + requester_document, serialized_contender, CREATED_AT_MS, + }; + use crate::types::dash_sdk_contest_info_free; + use dash_sdk::dpp::document::DocumentV0Getters; + use dash_sdk::dpp::voting::contender_structs::{ + ContenderWithSerializedDocument, ContenderWithSerializedDocumentV0, + }; + use std::collections::BTreeMap; + + /// A contest whose requests are `(owner, label, $createdAt)`; a `None` + /// label stands for a contender whose document does not decode. + fn contest(requests: &[(u8, Option<&str>, u64)]) -> DashSDKContestInfo { + let contenders = requests + .iter() + .map(|(owner, label, created_at)| match label { + Some(label) => { + let document = requester_document(*owner, label, Some(*created_at)); + (document.owner_id(), serialized_contender(document)) + } + None => { + let identity_id = Identifier::from([*owner; 32]); + let contender = + ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id, + serialized_document: Some(vec![0xde, 0xad]), + vote_tally: Some(1), + }); + (identity_id, contender) + } + }) + .collect::>(); + let platform_version = PlatformVersion::latest(); + let dpns_domain_type = + load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS system contract") + .document_type_cloned_for_name("domain") + .expect("domain"); + contest_info_to_ffi( + Contenders { + winner: None, + contenders, + abstain_vote_tally: Some(2), + lock_vote_tally: Some(5), + }, + 42, + Some(dpns_domain_type.as_ref()), + platform_version, + ) + } + + unsafe fn c_str(ptr: *mut c_char) -> String { + CStr::from_ptr(ptr).to_string_lossy().into_owned() + } + + #[test] + fn should_carry_each_contenders_label_and_date() { + let info = contest(&[(1, Some("pizza"), CREATED_AT_MS), (2, None, 0)]); + unsafe { + assert_eq!(info.contender_count, 2); + let contenders = std::slice::from_raw_parts(info.contenders, 2); + let first = &contenders[0]; + assert_eq!(c_str(first.label), "pizza"); + assert!(first.has_created_at); + assert_eq!(first.created_at, CREATED_AT_MS); + let second = &contenders[1]; + assert!(second.label.is_null()); + assert!(!second.has_created_at); + assert_eq!(info.requested_label_count, 1); + assert_eq!( + (info.abstain_votes, info.lock_votes, info.end_time), + (2, 5, 42) + ); + dash_sdk_contest_info_free(Box::into_raw(Box::new(info))); + } + } + + #[test] + fn should_list_requested_labels_once_in_contender_order() { + let info = contest(&[ + (1, Some("pizza"), CREATED_AT_MS), + (2, Some("p1zza"), CREATED_AT_MS + 1), + (3, Some("pizza"), CREATED_AT_MS + 2), + ]); + unsafe { + let labels = + std::slice::from_raw_parts(info.requested_labels, info.requested_label_count) + .iter() + .map(|label| c_str(*label)) + .collect::>(); + assert_eq!(labels, ["pizza", "p1zza"]); + dash_sdk_contest_info_free(Box::into_raw(Box::new(info))); + } + } +} diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index 913d25148c2..8ff03e01253 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -1041,6 +1041,13 @@ pub struct DashSDKContender { /// be decoded; callers fall back to the normalized name rather than /// guessing a spelling. pub label: *mut c_char, + /// When this contender submitted its request: the document's `$createdAt`, + /// in milliseconds since the Unix epoch. Meaningful only when + /// `has_created_at` is true. + pub created_at: u64, + /// False when the document could not be decoded or its `$createdAt` is + /// absent or 0; `created_at` is then 0 and must be ignored. + pub has_created_at: bool, } /// Represents contest information for a DPNS name @@ -1742,6 +1749,8 @@ mod tests { Some(l) => CString::new(l).unwrap().into_raw(), None => std::ptr::null_mut(), }, + created_at: 0, + has_created_at: false, } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index 1eff7ce5f9e..e00b3fa8dcf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -1231,6 +1231,12 @@ extension SDK { let label = String(cString: labelPtr) if !label.isEmpty { contenderDict["label"] = label } } + // When this contender submitted its request, in + // milliseconds since the epoch (as `endTime`). Absent + // when the FFI could not read the document's date. + if contender.has_created_at { + contenderDict["createdAt"] = contender.created_at + } contenders.append(contenderDict) } @@ -1372,6 +1378,12 @@ extension SDK { let label = String(cString: labelPtr) if !label.isEmpty { contenderDict["label"] = label } } + // When this contender submitted its request, in + // milliseconds since the epoch (as `endTime`). Absent + // when the FFI could not read the document's date. + if contender.has_created_at { + contenderDict["createdAt"] = contender.created_at + } contenders.append(contenderDict) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift index 30ea1590ee9..68be881f0eb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift @@ -111,13 +111,29 @@ public struct DPNSContender: Sendable, Identifiable, Equatable { /// show ``identityId`` or the contest's normalized label rather than /// inventing a spelling. public let displayLabel: String? + /// When this contender submitted its request: its `domain` document's + /// `$createdAt`. + /// + /// Decoded by the FFI from the same document as ``displayLabel``. `nil` when + /// that decode failed or the document carries no usable `$createdAt` (absent + /// or 0): a missing date, not a sign the label is wrong. Comparing dates + /// across contenders ("submitted first") is only meaningful when every + /// contender of the contest has one and the query was not cut short by its + /// `limit`. + public let createdAt: Date? public var id: String { identityId } - public init(identityId: String, voteTally: UInt32, displayLabel: String? = nil) { + public init( + identityId: String, + voteTally: UInt32, + displayLabel: String? = nil, + createdAt: Date? = nil + ) { self.identityId = identityId self.voteTally = voteTally self.displayLabel = displayLabel + self.createdAt = createdAt } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift index 7796b4ffe4b..6f54fae928a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift @@ -214,16 +214,9 @@ extension SDK { if info.contender_count > 0, let contendersPtr = info.contenders { contenders.reserveCapacity(Int(info.contender_count)) for contenderIndex in 0.. 0 - ? Date(timeIntervalSince1970: Double(info.end_time) / 1000) - : nil, + endTime: Self.date(fromMilliseconds: info.end_time), hasWinner: info.has_winner, abstainVotes: info.abstain_votes, lockVotes: info.lock_votes, @@ -255,6 +246,42 @@ extension SDK { return contests.sorted { $0.normalizedLabel < $1.normalizedLabel } } + /// One C contender as a Swift value; copies, never frees (the list owner + /// frees). `nil` without an identity id. + /// + /// `internal` so the decoder tests can build a `DashSDKContender` by hand + /// without a live list. + nonisolated static func contender(from contender: DashSDKContender) -> DPNSContender? { + guard let idPtr = contender.identity_id else { return nil } + // `label` is null when the FFI could not decode that contender's + // document — a missing display name, not a reason to drop the row. + let displayLabel = contender.label.map { String(cString: $0) } + return DPNSContender( + identityId: String(cString: idPtr), + voteTally: contender.vote_count, + displayLabel: displayLabel?.isEmpty == false ? displayLabel : nil, + // The flag alone says whether the FFI decoded a date. + createdAt: contender.has_created_at + ? Date(timeIntervalSince1970: Double(contender.created_at) / 1000) + : nil) + } + + /// Milliseconds since the epoch as a `Date`; `nil` for 0, the FFI's "none" + /// for a timestamp field without a flag. + nonisolated static func date(fromMilliseconds milliseconds: UInt64) -> Date? { + milliseconds > 0 ? Date(timeIntervalSince1970: Double(milliseconds) / 1000) : nil + } + + /// A JSON millisecond timestamp as a `Date`; `nil` unless it is a positive + /// integer. `JSONSerialization` hands booleans back as `NSNumber` too, and a + /// negative value would wrap through `uint64Value`, so both are refused. + nonisolated static func date(fromJSONMilliseconds value: Any?) -> Date? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + number.int64Value > 0 else { return nil } + return date(fromMilliseconds: number.uint64Value) + } + // MARK: - One contest's vote state /// Full vote state for a single contest: contenders with tallies, the @@ -348,10 +375,11 @@ extension SDK { /// "winner_info":"NoWinner"|"Locked"|{"type":"WonByIdentity","identity_id":"…"}, /// "block_info":{"height":…,"core_height":…,"timestamp":…}, /// "contenders":[{"identity_id":"…","vote_count":N,"document":"hex"|null, - /// "label":"pizza"}]}` + /// "label":"pizza","created_at":}]}` /// - /// `label` is the requester's own spelling, decoded FFI-side from the - /// contender document; it is omitted when that decode was not possible. + /// `label` is the requester's own spelling and `created_at` its document's + /// `$createdAt` in milliseconds, both decoded FFI-side from the contender + /// document; each is omitted when that decode was not possible. /// /// `winner_info` and `block_info` are absent while voting is open; when /// either is present the poll has finished (see ``DPNSContestOutcome``). @@ -369,7 +397,10 @@ extension SDK { // `label` is absent when the FFI could not decode the document; that // is a missing display name, not a reason to drop the contender. let label = (entry["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } - return DPNSContender(identityId: identityId, voteTally: tally, displayLabel: label) + // `created_at` is absent on the same terms as `label`. + let createdAt = Self.date(fromJSONMilliseconds: entry["created_at"]) + return DPNSContender( + identityId: identityId, voteTally: tally, displayLabel: label, createdAt: createdAt) } let outcome: DPNSContestOutcome = { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift index fe049d72006..349a39424a2 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift @@ -285,6 +285,63 @@ final class DPNSContestDecoderTests: XCTestCase { "callers must fall back to the normalized label, not guess one") } + // MARK: - Request dates + + func testCreatedAtIsDecodedFromMilliseconds() { + var entry = contender("Ab1", 3, label: "pizza") + entry["created_at"] = 1_760_140_800_123 + let state = decode(["contenders": [entry]]) + + XCTAssertEqual( + state.contenders.first?.createdAt, + Date(timeIntervalSince1970: 1_760_140_800.123)) + } + + func testMissingCreatedAtIsNil() { + // Absent on the same terms as `label`: the document did not decode. + let state = decode(["contenders": [contender("Ab1", 3)]]) + + XCTAssertNil(state.contenders.first?.createdAt) + } + + func testZeroOrMalformedCreatedAtIsNil() { + var zero = contender("Ab1", 3) + zero["created_at"] = 0 + var text = contender("Cd2", 1) + text["created_at"] = "yesterday" + var negative = contender("Ef3", 1) + negative["created_at"] = -1 + var bool = contender("Gh4", 1) + bool["created_at"] = true + let state = decode(["contenders": [zero, text, negative, bool]]) + + XCTAssertEqual(state.contenders.map(\.createdAt), [nil, nil, nil, nil]) + } + + func testListContenderCarriesCreatedAtOnlyWhenFlagged() { + "Ab1".withCString { id in + let idPtr = UnsafeMutablePointer(mutating: id) + let flagged = DashSDKContender( + identity_id: idPtr, vote_count: 3, label: nil, + created_at: 1_760_140_800_000, has_created_at: true) + let unflagged = DashSDKContender( + identity_id: idPtr, vote_count: 3, label: nil, + created_at: 1_760_140_800_000, has_created_at: false) + + XCTAssertEqual( + SDK.contender(from: flagged)?.createdAt, + Date(timeIntervalSince1970: 1_760_140_800)) + XCTAssertNil(SDK.contender(from: unflagged)?.createdAt) + } + } + + func testListContenderWithoutIdentityIdIsDropped() { + let contender = DashSDKContender( + identity_id: nil, vote_count: 3, label: nil, created_at: 0, has_created_at: false) + + XCTAssertNil(SDK.contender(from: contender)) + } + // MARK: - Vote poll coordinates func testIndexValuesPutTheParentDomainFirst() { From 083ad6d2b8674c6f33d6a5222d91d7c5b1cc365c Mon Sep 17 00:00:00 2001 From: romchornyi Date: Mon, 2 Nov 2026 21:27:38 +0200 Subject: [PATCH 2/2] refactor(sdk): decode DPNS contender requests in rs-sdk The requester's own spelling, the `$createdAt` of each contender's document and the ordered, de-duplicated list of requested labels were derived in rs-sdk-ffi. They now come from `dpns_usernames::ContenderRequest` and `ContestRequests` in rs-sdk; the list queries and the vote-state query only marshal the results into their C and JSON shapes. A present `$createdAt` of 0 is passed through as the document holds it instead of being treated as absent, in the C contender (`has_created_at`), the vote-state JSON and the Swift readers. Co-Authored-By: Claude Opus 5.5 --- .../contested_resource/contender_document.rs | 239 ++++--------- .../contested_resource/queries/vote_state.rs | 11 +- .../rs-sdk-ffi/src/dpns/queries/contested.rs | 64 ++-- packages/rs-sdk-ffi/src/types.rs | 11 +- .../dpns_usernames/contender_request.rs | 321 ++++++++++++++++++ .../rs-sdk/src/platform/dpns_usernames/mod.rs | 2 + .../FFI/PlatformQueryExtensions.swift | 12 +- .../SwiftDashSDK/Voting/DPNSContest.swift | 14 +- .../Voting/SDK+DPNSContests.swift | 40 ++- .../DPNSContestDecoderTests.swift | 27 +- 10 files changed, 493 insertions(+), 248 deletions(-) create mode 100644 packages/rs-sdk/src/platform/dpns_usernames/contender_request.rs diff --git a/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs b/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs index 96ef14e5bdb..796a6579c12 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs @@ -1,107 +1,53 @@ -//! What the FFI reads out of a contender's serialized document. +//! The C and JSON shapes of a contender's request. //! -//! Every contender of a contested name carries the document it submitted -//! (for DPNS, its `domain` document). The list queries and the vote-state -//! query both decode it to give callers the requester's own spelling and the -//! time the request was made; this module is the one place that decode lives. +//! Every contender of a contested name carries the document it submitted. +//! The SDK decodes it into a [`ContenderRequest`] (the requester's own +//! spelling and the time of the request); this module only marshals that +//! result for the list queries and the vote-state query. use crate::types::DashSDKContender; -use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; -use dash_sdk::dpp::document::{Document, DocumentV0Getters}; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocument; +use dash_sdk::platform::dpns_usernames::ContenderRequest; use std::ffi::CString; use std::os::raw::c_char; -/// Fields decoded from a contender's document. Both are `None` when the -/// document could not be decoded. -#[derive(Debug, Default, PartialEq, Eq)] -pub(crate) struct ContenderDocumentFields { - /// The label exactly as the contender typed it ("pizza"), where the - /// contest is keyed by its normalized form ("p1zza"). `None` when empty. - pub label: Option, - /// The document's `$createdAt`, in milliseconds since the Unix epoch: - /// when this request was submitted. `None` for 0, which is not a time a - /// document can have been created at, so every binding reads it the same - /// way. - pub created_at_ms: Option, -} - -impl ContenderDocumentFields { - /// The C contender the list queries hand out. Takes ownership of - /// `identity_id`; the label is allocated here. Both are released by - /// `free_contender_fields`, which every contender destructor goes through. - pub(crate) fn into_ffi_contender( - self, - identity_id: *mut c_char, - vote_count: u32, - ) -> DashSDKContender { - DashSDKContender { - identity_id, - vote_count, - label: self - .label - .and_then(|label| CString::new(label).ok()) - .map(CString::into_raw) - .unwrap_or(std::ptr::null_mut()), - created_at: self.created_at_ms.unwrap_or(0), - has_created_at: self.created_at_ms.is_some(), - } - } - - /// The optional members of a vote-state contender object, each with its - /// leading comma: `,"label":"pizza","created_at":1760140800000`. A field - /// that did not decode is left out, never written as null. - pub(crate) fn json_members(&self) -> String { - let label = self +/// The C contender the list queries hand out. Takes ownership of +/// `identity_id`; the label is allocated here. Both are released by +/// `free_contender_fields`, which every contender destructor goes through. +pub(crate) fn ffi_contender( + request: ContenderRequest, + identity_id: *mut c_char, + vote_count: u32, +) -> DashSDKContender { + DashSDKContender { + identity_id, + vote_count, + label: request .label - .as_ref() - .and_then(|label| serde_json::to_string(label).ok()) - .map(|label| format!(r#","label":{}"#, label)) - .unwrap_or_default(); - let created_at = self - .created_at_ms - .map(|created_at| format!(r#","created_at":{}"#, created_at)) - .unwrap_or_default(); - label + &created_at + .and_then(|label| CString::new(label).ok()) + .map(CString::into_raw) + .unwrap_or(std::ptr::null_mut()), + created_at: request.created_at.unwrap_or(0), + has_created_at: request.created_at.is_some(), } } -/// Decode `contender`'s document against `document_type` and read its fields. -/// -/// Best-effort: a missing document type, a contender without a serialized -/// document, or a document that does not decode gives empty fields rather than -/// an error, so one bad contender never fails the whole query. -pub(crate) fn contender_document_fields( - contender: &ContenderWithSerializedDocument, - document_type: Option, - platform_version: &PlatformVersion, -) -> ContenderDocumentFields { - let Some(document_type) = document_type else { - return ContenderDocumentFields::default(); - }; - let Ok(decoded) = contender.try_to_contender(document_type, platform_version) else { - return ContenderDocumentFields::default(); - }; - document_fields(decoded.document().as_ref()) +/// The optional members of a vote-state contender object, each with its +/// leading comma: `,"label":"pizza","created_at":1760140800000`. A field +/// that did not decode is left out, never written as null. +pub(crate) fn json_members(request: &ContenderRequest) -> String { + let label = request + .label + .as_ref() + .and_then(|label| serde_json::to_string(label).ok()) + .map(|label| format!(r#","label":{}"#, label)) + .unwrap_or_default(); + let created_at = request + .created_at + .map(|created_at| format!(r#","created_at":{}"#, created_at)) + .unwrap_or_default(); + label + &created_at } -fn document_fields(document: Option<&Document>) -> ContenderDocumentFields { - let Some(document) = document else { - return ContenderDocumentFields::default(); - }; - ContenderDocumentFields { - label: document - .get("label") - .and_then(|value| value.as_str()) - .filter(|label| !label.is_empty()) - .map(str::to_string), - created_at_ms: document.created_at().filter(|created_at| *created_at > 0), - } -} - -/// Real DPNS `domain` documents for tests, serialized the way Platform hands -/// contenders back. #[cfg(test)] pub(crate) mod test_support { use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -117,10 +63,6 @@ pub(crate) mod test_support { pub(crate) const CREATED_AT_MS: u64 = 1_760_140_800_000; - pub(crate) fn domain_document(created_at: Option) -> Document { - requester_document(7, "pizza", created_at) - } - /// A `domain` document for `label`, owned by the identity `[owner; 32]`. pub(crate) fn requester_document(owner: u8, label: &str, created_at: Option) -> Document { let owner_id = Identifier::from([owner; 32]); @@ -184,81 +126,21 @@ pub(crate) mod test_support { #[cfg(test)] mod tests { - use super::test_support::{domain_document, serialized_contender, CREATED_AT_MS}; + use super::test_support::CREATED_AT_MS; use super::*; - use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; - use dash_sdk::dpp::identifier::Identifier; - use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; - use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocumentV0; use std::ffi::CStr; - fn fields(contender: &ContenderWithSerializedDocument) -> ContenderDocumentFields { - let platform_version = PlatformVersion::latest(); - let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) - .expect("DPNS system contract"); - let document_type = contract.document_type_for_name("domain").ok(); - contender_document_fields(contender, document_type, platform_version) - } - - #[test] - fn should_read_label_and_created_at() { - let contender = serialized_contender(domain_document(Some(CREATED_AT_MS))); - assert_eq!( - fields(&contender), - ContenderDocumentFields { - label: Some("pizza".to_string()), - created_at_ms: Some(CREATED_AT_MS), - } - ); - } - - #[test] - fn should_keep_the_label_without_created_at() { - assert_eq!( - document_fields(Some(&domain_document(None))), - ContenderDocumentFields { - label: Some("pizza".to_string()), - created_at_ms: None, - } - ); - } - - #[test] - fn should_treat_a_zero_created_at_as_absent() { - assert_eq!( - document_fields(Some(&domain_document(Some(0)))).created_at_ms, - None - ); - } - - #[test] - fn should_give_no_fields_for_an_undecodable_document() { - let contender = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { - identity_id: Identifier::from([7u8; 32]), - serialized_document: Some(vec![0xde, 0xad, 0xbe, 0xef]), - vote_tally: Some(1), - }); - assert_eq!(fields(&contender), ContenderDocumentFields::default()); - } - - #[test] - fn should_give_no_fields_without_a_document() { - let contender = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { - identity_id: Identifier::from([7u8; 32]), - serialized_document: None, - vote_tally: Some(1), - }); - assert_eq!(fields(&contender), ContenderDocumentFields::default()); + fn request(label: Option<&str>, created_at: Option) -> ContenderRequest { + ContenderRequest { + label: label.map(str::to_string), + created_at, + } } #[test] - fn should_fill_the_ffi_contender_from_decoded_fields() { - let fields = ContenderDocumentFields { - label: Some("pizza".to_string()), - created_at_ms: Some(CREATED_AT_MS), - }; + fn should_fill_the_ffi_contender_from_the_request() { let id = CString::new("Ab1").unwrap().into_raw(); - let contender = fields.into_ffi_contender(id, 4); + let contender = ffi_contender(request(Some("pizza"), Some(CREATED_AT_MS)), id, 4); unsafe { assert_eq!(contender.vote_count, 4); assert!(contender.has_created_at); @@ -272,36 +154,37 @@ mod tests { #[test] fn should_flag_a_missing_date_in_the_ffi_contender() { let id = CString::new("Ab1").unwrap().into_raw(); - let contender = ContenderDocumentFields::default().into_ffi_contender(id, 0); + let contender = ffi_contender(ContenderRequest::default(), id, 0); assert!(!contender.has_created_at); assert_eq!(contender.created_at, 0); assert!(contender.label.is_null()); unsafe { drop(CString::from_raw(contender.identity_id)) }; } + #[test] + fn should_keep_a_present_zero_date_in_the_ffi_contender() { + let id = CString::new("Ab1").unwrap().into_raw(); + let contender = ffi_contender(request(None, Some(0)), id, 0); + assert!(contender.has_created_at); + assert_eq!(contender.created_at, 0); + unsafe { drop(CString::from_raw(contender.identity_id)) }; + } + #[test] fn should_write_label_and_created_at_json_members() { - let fields = ContenderDocumentFields { - label: Some("pi\"zza".to_string()), - created_at_ms: Some(CREATED_AT_MS), - }; assert_eq!( - fields.json_members(), + json_members(&request(Some("pi\"zza"), Some(CREATED_AT_MS))), format!(r#","label":"pi\"zza","created_at":{}"#, CREATED_AT_MS) ); } #[test] - fn should_leave_out_json_members_that_did_not_decode() { - assert_eq!(ContenderDocumentFields::default().json_members(), ""); + fn should_write_a_present_zero_created_at_json_member() { + assert_eq!(json_members(&request(None, Some(0))), r#","created_at":0"#); } #[test] - fn should_give_no_fields_without_a_document_type() { - let contender = serialized_contender(domain_document(Some(CREATED_AT_MS))); - assert_eq!( - contender_document_fields(&contender, None, PlatformVersion::latest()), - ContenderDocumentFields::default() - ); + fn should_leave_out_json_members_that_did_not_decode() { + assert_eq!(json_members(&ContenderRequest::default()), ""); } } diff --git a/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs b/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs index f5cb39bafe8..03a984c7c36 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/queries/vote_state.rs @@ -1,4 +1,4 @@ -use crate::contested_resource::contender_document::contender_document_fields; +use crate::contested_resource::contender_document::json_members; use crate::types::SDKHandle; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -7,6 +7,7 @@ use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocument; use dash_sdk::dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use dash_sdk::drive::query::vote_poll_vote_state_query::ContestedDocumentVotePollDriveQuery; +use dash_sdk::platform::dpns_usernames::ContenderRequest; use dash_sdk::platform::FetchMany; use dash_sdk::platform::{DataContract, Fetch}; use dash_sdk::query_types::Contenders; @@ -245,7 +246,9 @@ fn get_contested_resource_vote_state( // typo to the person who submitted it. // // The same decode gives `created_at`, the document's - // `$createdAt` in milliseconds: when the request was made. + // `$createdAt` in milliseconds: when the request was made, + // passed on as the document holds it (0 included). The + // decode is the SDK's `ContenderRequest`. // // Best-effort: the contract fetch or a single decode // failing must not fail the whole query. `label` and @@ -268,7 +271,7 @@ fn get_contested_resource_vote_state( r#""document":null"#.to_string() }; - let fields = contender_document_fields( + let request = ContenderRequest::decode( contender, document_type, sdk.version(), @@ -281,7 +284,7 @@ fn get_contested_resource_vote_state( bs58::encode(id.as_bytes()).into_string(), vote_count, document_json, - fields.json_members() + json_members(&request) ) }) .collect(); diff --git a/packages/rs-sdk-ffi/src/dpns/queries/contested.rs b/packages/rs-sdk-ffi/src/dpns/queries/contested.rs index a99be96ad37..76267e994f0 100644 --- a/packages/rs-sdk-ffi/src/dpns/queries/contested.rs +++ b/packages/rs-sdk-ffi/src/dpns/queries/contested.rs @@ -6,7 +6,7 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::time::Duration; -use crate::contested_resource::contender_document::contender_document_fields; +use crate::contested_resource::contender_document::ffi_contender; use crate::sdk::SDKWrapper; use crate::types::{ DashSDKContender, DashSDKContestInfo, DashSDKContestedName, DashSDKContestedNamesList, @@ -17,6 +17,8 @@ use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::identifier::Identifier; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::voting::contender_structs::ContenderWithSerializedDocument; +use dash_sdk::platform::dpns_usernames::ContestRequests; use dash_sdk::query_types::Contenders; use serde_json::json; // Still used by other functions @@ -638,39 +640,45 @@ pub unsafe extern "C" fn dash_sdk_dpns_get_contested_non_resolved_usernames( /// One contest in its C shape, shared by both list queries. /// -/// Each contender's document is decoded once, for the requester's own -/// spelling and the time of the request. The ordered, de-duplicated requested -/// labels are derived here so the display policy lives in one place rather -/// than being re-implemented by each language binding. A contender whose id -/// cannot be represented as a C string is skipped. +/// The SDK decodes the contenders' requests and derives the contest's +/// requested labels (`ContestRequests`); this only allocates their C shapes. fn contest_info_to_ffi( contenders: Contenders, end_time: u64, dpns_domain_type: Option, platform_version: &PlatformVersion, ) -> DashSDKContestInfo { - let mut ffi_contenders = Vec::with_capacity(contenders.contenders.len()); - let mut requested_labels: Vec<*mut c_char> = Vec::new(); - let mut seen_labels: Vec = Vec::new(); - - for (contender_id, votes) in contenders.contenders { - let Ok(c_id) = CString::new(contender_id.to_string(Encoding::Base58)) else { - continue; - }; - let vote_count = votes.vote_tally().unwrap_or(0); - let fields = contender_document_fields(&votes, dpns_domain_type, platform_version); - - if let Some(label) = fields.label.as_ref() { - if !seen_labels.contains(label) { - if let Ok(c_label) = CString::new(label.as_str()) { - seen_labels.push(label.clone()); - requested_labels.push(c_label.into_raw()); - } - } - } - - ffi_contenders.push(fields.into_ffi_contender(c_id.into_raw(), vote_count)); - } + // A contender whose id cannot be represented as a C string is left out + // before its request is decoded, so it contributes no label either. + let kept: Vec<(CString, ContenderWithSerializedDocument)> = contenders + .contenders + .into_iter() + .filter_map(|(contender_id, votes)| { + let c_id = CString::new(contender_id.to_string(Encoding::Base58)).ok()?; + Some((c_id, votes)) + }) + .collect(); + let contest = ContestRequests::decode( + kept.iter().map(|(_, votes)| votes), + dpns_domain_type, + platform_version, + ); + + let requested_labels: Vec<*mut c_char> = contest + .requested_labels + .into_iter() + .filter_map(|label| CString::new(label).ok()) + .map(CString::into_raw) + .collect(); + + // `ContestRequests::decode` keeps one request per contender, in order. + let ffi_contenders: Vec = kept + .into_iter() + .zip(contest.requests) + .map(|((c_id, votes), request)| { + ffi_contender(request, c_id.into_raw(), votes.vote_tally().unwrap_or(0)) + }) + .collect(); let requested_label_count = requested_labels.len(); let requested_labels_ptr = if requested_labels.is_empty() { diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index 8ff03e01253..fbb0da1fb27 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -1045,8 +1045,9 @@ pub struct DashSDKContender { /// in milliseconds since the Unix epoch. Meaningful only when /// `has_created_at` is true. pub created_at: u64, - /// False when the document could not be decoded or its `$createdAt` is - /// absent or 0; `created_at` is then 0 and must be ignored. + /// False when the document could not be decoded or carries no + /// `$createdAt`; `created_at` is then 0 and must be ignored. A present 0 + /// is passed on as `true` with `created_at` 0. pub has_created_at: bool, } @@ -1059,9 +1060,9 @@ pub struct DashSDKContestInfo { pub contender_count: usize, /// The distinct labels the contenders requested, in contender order and /// already de-duplicated — `["pizza", "p1zza"]` for a contest normalized to - /// `"p1zza"`. Derived here rather than by the caller so display policy has - /// one home. Null/zero when no contender document could be decoded; callers - /// fall back to the normalized contest name. + /// `"p1zza"`. Derived by the SDK (`dpns_usernames::ContestRequests`) so + /// display policy has one home. Null/zero when no contender has a usable + /// label; callers fall back to the normalized contest name. pub requested_labels: *mut *mut c_char, /// Number of entries in `requested_labels`. pub requested_label_count: usize, diff --git a/packages/rs-sdk/src/platform/dpns_usernames/contender_request.rs b/packages/rs-sdk/src/platform/dpns_usernames/contender_request.rs new file mode 100644 index 00000000000..39e598ce22a --- /dev/null +++ b/packages/rs-sdk/src/platform/dpns_usernames/contender_request.rs @@ -0,0 +1,321 @@ +//! What a contender of a contested DPNS name asked for. +//! +//! Every contender of a contested name carries the `domain` document it +//! submitted. The contest is keyed by the normalized label ("p1zza"), while +//! the document records the spelling the requester typed ("pizza") and when +//! the request was made. Bindings read both from here rather than decoding +//! the document themselves. + +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::contender_structs::ContenderWithSerializedDocument; +use std::collections::HashSet; +use tracing::debug; + +/// One contender's request, read from its own document. Both fields are +/// `None` when the document could not be decoded. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ContenderRequest { + /// The label exactly as the contender typed it. `None` when empty. + pub label: Option, + /// The document's `$createdAt`: when this request was submitted. Passed + /// on as the document holds it, so a present 0 stays `Some(0)`. + pub created_at: Option, +} + +impl ContenderRequest { + /// Decode `contender`'s document against `document_type` and read the + /// request from it. + /// + /// Best-effort: a missing document type, a contender without a serialized + /// document, or a document that does not decode gives an empty request + /// rather than an error, so one bad contender never fails a whole query. + pub fn decode( + contender: &ContenderWithSerializedDocument, + document_type: Option, + platform_version: &PlatformVersion, + ) -> Self { + let Some(document_type) = document_type else { + return Self::default(); + }; + let decoded = match contender.try_to_contender(document_type, platform_version) { + Ok(decoded) => decoded, + Err(error) => { + debug!( + contender = %contender.identity_id_ref(), + %error, + "contender document did not decode; its request is left empty" + ); + return Self::default(); + } + }; + Self::from_document(decoded.document().as_ref()) + } + + /// Read the request from an already decoded `domain` document. + fn from_document(document: Option<&Document>) -> Self { + let Some(document) = document else { + return Self::default(); + }; + Self { + label: document + .get("label") + .and_then(|value| value.as_str()) + .filter(|label| !label.is_empty()) + .map(str::to_string), + created_at: document.created_at(), + } + } +} + +/// Every contender's request in one contest, and the spellings they asked for. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ContestRequests { + /// One request per contender, in the order the contenders were given. + pub requests: Vec, + /// The distinct requested spellings, each once, in contender order. + /// Contenders whose label did not decode contribute nothing. + pub requested_labels: Vec, +} + +impl ContestRequests { + /// Decode each contender's document once (see [`ContenderRequest::decode`]) + /// and derive the contest's requested labels from the results. + pub fn decode<'a>( + contenders: impl IntoIterator, + document_type: Option, + platform_version: &PlatformVersion, + ) -> Self { + let requests: Vec = contenders + .into_iter() + .map(|contender| ContenderRequest::decode(contender, document_type, platform_version)) + .collect(); + let requested_labels = requested_labels(&requests); + Self { + requests, + requested_labels, + } + } +} + +/// The spellings requested for one contest, each once, in contender order. +/// +/// Contenders whose label did not decode contribute nothing. +fn requested_labels<'a>(requests: impl IntoIterator) -> Vec { + let mut seen: HashSet<&str> = HashSet::new(); + let mut labels: Vec = Vec::new(); + for label in requests + .into_iter() + .filter_map(|request| request.label.as_deref()) + { + if seen.insert(label) { + labels.push(label.to_string()); + } + } + labels +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::DocumentV0; + use dpp::platform_value::{Identifier, Value}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::voting::contender_structs::{ + Contender, ContenderV0, ContenderWithSerializedDocumentV0, + }; + use std::collections::BTreeMap; + + const CREATED_AT_MS: u64 = 1_760_140_800_000; + + fn domain_document(label: &str, created_at: Option) -> Document { + let owner_id = Identifier::from([7u8; 32]); + let text = |value: &str| Value::Text(value.to_string()); + let properties = BTreeMap::from([ + ("parentDomainName".to_string(), text("dash")), + ("normalizedParentDomainName".to_string(), text("dash")), + ("label".to_string(), text(label)), + ("normalizedLabel".to_string(), text("p1zza")), + ("preorderSalt".to_string(), Value::Bytes32([3u8; 32])), + ( + "records".to_string(), + Value::Map(vec![( + text("identity"), + Value::Identifier(owner_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![(text("allowSubdomains"), Value::Bool(false))]), + ), + ]); + Document::V0(DocumentV0 { + id: Identifier::from([107u8; 32]), + owner_id, + properties, + revision: Some(1), + created_at, + updated_at: created_at, + transferred_at: created_at, + ..Default::default() + }) + } + + fn decode(contender: &ContenderWithSerializedDocument) -> ContenderRequest { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS system contract"); + let document_type = contract.document_type_for_name("domain").ok(); + ContenderRequest::decode(contender, document_type, platform_version) + } + + fn serialized(document: Document) -> ContenderWithSerializedDocument { + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS system contract"); + let document_type = contract.document_type_for_name("domain").expect("domain"); + Contender::V0(ContenderV0 { + identity_id: document.owner_id(), + document: Some(document), + vote_tally: Some(3), + }) + .try_into_contender_with_serialized_document(document_type, &contract, platform_version) + .expect("serialize contender") + } + + fn request(label: Option<&str>) -> ContenderRequest { + ContenderRequest { + label: label.map(str::to_string), + created_at: None, + } + } + + #[test] + fn should_read_label_and_created_at() { + let contender = serialized(domain_document("pizza", Some(CREATED_AT_MS))); + assert_eq!( + decode(&contender), + ContenderRequest { + label: Some("pizza".to_string()), + created_at: Some(CREATED_AT_MS), + } + ); + } + + #[test] + fn should_keep_a_present_zero_created_at_through_serialization() { + let contender = serialized(domain_document("pizza", Some(0))); + assert_eq!(decode(&contender).created_at, Some(0)); + } + + #[test] + fn should_keep_the_label_without_created_at() { + let document = domain_document("pizza", None); + assert_eq!( + ContenderRequest::from_document(Some(&document)), + ContenderRequest { + label: Some("pizza".to_string()), + created_at: None, + } + ); + } + + #[test] + fn should_pass_a_present_zero_created_at_through() { + let document = domain_document("pizza", Some(0)); + assert_eq!( + ContenderRequest::from_document(Some(&document)).created_at, + Some(0) + ); + } + + #[test] + fn should_leave_an_empty_label_out() { + let document = domain_document("", Some(CREATED_AT_MS)); + assert_eq!(ContenderRequest::from_document(Some(&document)).label, None); + } + + #[test] + fn should_give_an_empty_request_for_an_undecodable_document() { + let contender = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id: Identifier::from([7u8; 32]), + serialized_document: Some(vec![0xde, 0xad, 0xbe, 0xef]), + vote_tally: Some(1), + }); + assert_eq!(decode(&contender), ContenderRequest::default()); + } + + #[test] + fn should_give_an_empty_request_without_a_document() { + let contender = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id: Identifier::from([7u8; 32]), + serialized_document: None, + vote_tally: Some(1), + }); + assert_eq!(decode(&contender), ContenderRequest::default()); + } + + #[test] + fn should_give_an_empty_request_without_a_document_type() { + let contender = serialized(domain_document("pizza", Some(CREATED_AT_MS))); + assert_eq!( + ContenderRequest::decode(&contender, None, PlatformVersion::latest()), + ContenderRequest::default() + ); + } + + #[test] + fn should_list_each_requested_label_once_in_contender_order() { + let requests = [ + request(Some("pizza")), + request(None), + request(Some("Pizza")), + request(Some("pizza")), + ]; + assert_eq!(requested_labels(&requests), vec!["pizza", "Pizza"]); + } + + #[test] + fn should_decode_a_contest_in_contender_order_with_its_labels() { + let undecodable = ContenderWithSerializedDocument::V0(ContenderWithSerializedDocumentV0 { + identity_id: Identifier::from([9u8; 32]), + serialized_document: Some(vec![0xde, 0xad]), + vote_tally: Some(1), + }); + let contenders = [ + serialized(domain_document("pizza", Some(CREATED_AT_MS))), + undecodable, + serialized(domain_document("Pizza", Some(CREATED_AT_MS + 2))), + serialized(domain_document("pizza", Some(CREATED_AT_MS + 1))), + ]; + let platform_version = PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("DPNS system contract"); + let document_type = contract.document_type_for_name("domain").ok(); + + let contest = ContestRequests::decode(&contenders, document_type, platform_version); + + assert_eq!( + contest + .requests + .iter() + .map(|request| request.created_at) + .collect::>(), + vec![ + Some(CREATED_AT_MS), + None, + Some(CREATED_AT_MS + 2), + Some(CREATED_AT_MS + 1) + ] + ); + assert_eq!(contest.requested_labels, vec!["pizza", "Pizza"]); + } + + #[test] + fn should_list_no_labels_when_none_decoded() { + assert!(requested_labels(&[request(None)]).is_empty()); + } +} diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index d4677138e99..0f498dc80e7 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -1,6 +1,8 @@ +mod contender_request; mod contested_queries; mod queries; +pub use contender_request::{ContenderRequest, ContestRequests}; pub use contested_queries::ContestedDpnsUsername; pub use dash_platform_queries::dpns_usernames::{ convert_to_homograph_safe_chars, is_contested_username, is_valid_username, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index e00b3fa8dcf..19cec7a9e48 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -1223,7 +1223,7 @@ extension SDK { contenderDict["votes"] = "ResourceVote { vote_choice: TowardsIdentity, strength: \(contender.vote_count) }" // The spelling this contender actually requested // ("pizza"), where the contest key is the normalized - // form ("p1zza"). Absent when the FFI could not decode + // form ("p1zza"). Absent when the SDK could not decode // their document. Prefer the typed // `dpnsActiveContests` / `dpnsContestsForIdentity`, // which also give the tally as an integer. @@ -1232,8 +1232,8 @@ extension SDK { if !label.isEmpty { contenderDict["label"] = label } } // When this contender submitted its request, in - // milliseconds since the epoch (as `endTime`). Absent - // when the FFI could not read the document's date. + // milliseconds since the epoch (as `endTime`), 0 + // included. Absent when the document carries no date. if contender.has_created_at { contenderDict["createdAt"] = contender.created_at } @@ -1370,7 +1370,7 @@ extension SDK { contenderDict["votes"] = "ResourceVote { vote_choice: TowardsIdentity, strength: \(contender.vote_count) }" // The spelling this contender actually requested // ("pizza"), where the contest key is the normalized - // form ("p1zza"). Absent when the FFI could not decode + // form ("p1zza"). Absent when the SDK could not decode // their document. Prefer the typed // `dpnsActiveContests` / `dpnsContestsForIdentity`, // which also give the tally as an integer. @@ -1379,8 +1379,8 @@ extension SDK { if !label.isEmpty { contenderDict["label"] = label } } // When this contender submitted its request, in - // milliseconds since the epoch (as `endTime`). Absent - // when the FFI could not read the document's date. + // milliseconds since the epoch (as `endTime`), 0 + // included. Absent when the document carries no date. if contender.has_created_at { contenderDict["createdAt"] = contender.created_at } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift index 68be881f0eb..b853288bee4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift @@ -106,7 +106,7 @@ public struct DPNSContender: Sendable, Identifiable, Equatable { /// The label exactly as this contender typed it — `"pizza"`, where the /// contest's normalized form is `"p1zza"`. /// - /// Decoded by the FFI from the contender's `domain` document. `nil` when the + /// Decoded by the SDK from the contender's `domain` document. `nil` when the /// label data was unavailable or decoding that contender's document failed — /// show ``identityId`` or the contest's normalized label rather than /// inventing a spelling. @@ -114,12 +114,12 @@ public struct DPNSContender: Sendable, Identifiable, Equatable { /// When this contender submitted its request: its `domain` document's /// `$createdAt`. /// - /// Decoded by the FFI from the same document as ``displayLabel``. `nil` when - /// that decode failed or the document carries no usable `$createdAt` (absent - /// or 0): a missing date, not a sign the label is wrong. Comparing dates - /// across contenders ("submitted first") is only meaningful when every - /// contender of the contest has one and the query was not cut short by its - /// `limit`. + /// Decoded by the SDK from the same document as ``displayLabel``. `nil` when + /// that decode failed or the document carries no `$createdAt`: a missing + /// date, not a sign the label is wrong. A document holding 0 gives the + /// epoch (1970), passed on as is. Comparing dates across contenders + /// ("submitted first") is only meaningful when every contender of the + /// contest has one and the query was not cut short by its `limit`. public let createdAt: Date? public var id: String { identityId } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift index 6f54fae928a..e722b3f1c75 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DPNSContests.swift @@ -235,7 +235,7 @@ extension SDK { normalizedLabel: normalizedLabel, // `end_time` is milliseconds since epoch; the FFI reports 0 when it // has none. Keep that as `nil` rather than rendering 1970. - endTime: Self.date(fromMilliseconds: info.end_time), + endTime: Self.date(fromSentinelMilliseconds: info.end_time), hasWinner: info.has_winner, abstainVotes: info.abstain_votes, lockVotes: info.lock_votes, @@ -253,33 +253,41 @@ extension SDK { /// without a live list. nonisolated static func contender(from contender: DashSDKContender) -> DPNSContender? { guard let idPtr = contender.identity_id else { return nil } - // `label` is null when the FFI could not decode that contender's + // `label` is null when the SDK could not decode that contender's // document — a missing display name, not a reason to drop the row. let displayLabel = contender.label.map { String(cString: $0) } return DPNSContender( identityId: String(cString: idPtr), voteTally: contender.vote_count, displayLabel: displayLabel?.isEmpty == false ? displayLabel : nil, - // The flag alone says whether the FFI decoded a date. - createdAt: contender.has_created_at - ? Date(timeIntervalSince1970: Double(contender.created_at) / 1000) - : nil) + // The flag alone says whether the SDK decoded a date. + createdAt: contender.has_created_at ? date(milliseconds: contender.created_at) : nil) } /// Milliseconds since the epoch as a `Date`; `nil` for 0, the FFI's "none" - /// for a timestamp field without a flag. - nonisolated static func date(fromMilliseconds milliseconds: UInt64) -> Date? { - milliseconds > 0 ? Date(timeIntervalSince1970: Double(milliseconds) / 1000) : nil + /// for a timestamp field without a presence flag (`end_time`). A field that + /// has a flag or can be omitted (`created_at`) does not go through this. + nonisolated static func date(fromSentinelMilliseconds milliseconds: UInt64) -> Date? { + milliseconds > 0 ? date(milliseconds: milliseconds) : nil } - /// A JSON millisecond timestamp as a `Date`; `nil` unless it is a positive - /// integer. `JSONSerialization` hands booleans back as `NSNumber` too, and a - /// negative value would wrap through `uint64Value`, so both are refused. + /// Milliseconds since the epoch as a `Date`, 0 included. + nonisolated static func date(milliseconds: UInt64) -> Date { + Date(timeIntervalSince1970: Double(milliseconds) / 1000) + } + + /// A JSON millisecond timestamp as a `Date`. The member's presence is what + /// says a date exists, so 0 is the epoch rather than "none"; `nil` when it + /// is missing or not a non-negative integer. `JSONSerialization` hands + /// booleans and floats back as `NSNumber` too, and a negative value would + /// wrap through `uint64Value`, so all three are refused. The SDK writes the + /// member as a JSON integer. nonisolated static func date(fromJSONMilliseconds value: Any?) -> Date? { guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), - number.int64Value > 0 else { return nil } - return date(fromMilliseconds: number.uint64Value) + !CFNumberIsFloatType(number), + number.int64Value >= 0 else { return nil } + return date(milliseconds: number.uint64Value) } // MARK: - One contest's vote state @@ -378,7 +386,7 @@ extension SDK { /// "label":"pizza","created_at":}]}` /// /// `label` is the requester's own spelling and `created_at` its document's - /// `$createdAt` in milliseconds, both decoded FFI-side from the contender + /// `$createdAt` in milliseconds, both decoded by the SDK from the contender /// document; each is omitted when that decode was not possible. /// /// `winner_info` and `block_info` are absent while voting is open; when @@ -394,7 +402,7 @@ extension SDK { .compactMap { entry in guard let identityId = entry["identity_id"] as? String else { return nil } let tally = (entry["vote_count"] as? NSNumber)?.uint32Value ?? 0 - // `label` is absent when the FFI could not decode the document; that + // `label` is absent when the SDK could not decode the document; that // is a missing display name, not a reason to drop the contender. let label = (entry["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } // `created_at` is absent on the same terms as `label`. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift index 349a39424a2..050a3722661 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift @@ -304,20 +304,29 @@ final class DPNSContestDecoderTests: XCTestCase { XCTAssertNil(state.contenders.first?.createdAt) } - func testZeroOrMalformedCreatedAtIsNil() { - var zero = contender("Ab1", 3) - zero["created_at"] = 0 + func testMalformedCreatedAtIsNil() { var text = contender("Cd2", 1) text["created_at"] = "yesterday" var negative = contender("Ef3", 1) negative["created_at"] = -1 var bool = contender("Gh4", 1) bool["created_at"] = true - let state = decode(["contenders": [zero, text, negative, bool]]) + var fraction = contender("Jk5", 1) + fraction["created_at"] = 0.4 + let state = decode(["contenders": [text, negative, bool, fraction]]) XCTAssertEqual(state.contenders.map(\.createdAt), [nil, nil, nil, nil]) } + func testPresentZeroCreatedAtIsTheEpoch() { + // Presence says the document has a date; the SDK passes 0 through as is. + var zero = contender("Ab1", 3) + zero["created_at"] = 0 + let state = decode(["contenders": [zero]]) + + XCTAssertEqual(state.contenders.first?.createdAt, Date(timeIntervalSince1970: 0)) + } + func testListContenderCarriesCreatedAtOnlyWhenFlagged() { "Ab1".withCString { id in let idPtr = UnsafeMutablePointer(mutating: id) @@ -335,6 +344,16 @@ final class DPNSContestDecoderTests: XCTestCase { } } + func testListContenderKeepsAFlaggedZeroDate() { + "Ab1".withCString { id in + let zero = DashSDKContender( + identity_id: UnsafeMutablePointer(mutating: id), vote_count: 3, label: nil, + created_at: 0, has_created_at: true) + + XCTAssertEqual(SDK.contender(from: zero)?.createdAt, Date(timeIntervalSince1970: 0)) + } + } + func testListContenderWithoutIdentityIdIsDropped() { let contender = DashSDKContender( identity_id: nil, vote_count: 3, label: nil, created_at: 0, has_created_at: false)