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..796a6579c12 --- /dev/null +++ b/packages/rs-sdk-ffi/src/contested_resource/contender_document.rs @@ -0,0 +1,190 @@ +//! The C and JSON shapes of a contender's request. +//! +//! 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::platform::dpns_usernames::ContenderRequest; +use std::ffi::CString; +use std::os::raw::c_char; + +/// 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 + .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(), + } +} + +/// 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 +} + +#[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; + + /// 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::CREATED_AT_MS; + use super::*; + use std::ffi::CStr; + + 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_the_request() { + let id = CString::new("Ab1").unwrap().into_raw(); + 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); + 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 = 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() { + assert_eq!( + json_members(&request(Some("pi\"zza"), Some(CREATED_AT_MS))), + format!(r#","label":"pi\"zza","created_at":{}"#, CREATED_AT_MS) + ); + } + + #[test] + 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_leave_out_json_members_that_did_not_decode() { + assert_eq!(json_members(&ContenderRequest::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..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,12 +1,13 @@ +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; -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; 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; @@ -244,12 +245,21 @@ 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, + // 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, 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 +271,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 request = ContenderRequest::decode( + contender, + document_type, + sdk.version(), + ); let vote_count = contender.vote_tally().unwrap_or(0); @@ -290,7 +284,7 @@ fn get_contested_resource_vote_state( bs58::encode(id.as_bytes()).into_string(), vote_count, document_json, - label_json + 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 128167943d7..76267e994f0 100644 --- a/packages/rs-sdk-ffi/src/dpns/queries/contested.rs +++ b/packages/rs-sdk-ffi/src/dpns/queries/contested.rs @@ -1,20 +1,25 @@ //! 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::ffi_contender; 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::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 /// A list query fans out to the vote state of every current contest. Keep one @@ -514,87 +519,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 +606,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 +637,170 @@ 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. +/// +/// 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 { + // 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() { + 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..fbb0da1fb27 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -1041,6 +1041,14 @@ 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 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, } /// Represents contest information for a DPNS name @@ -1052,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, @@ -1742,6 +1750,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/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 1eff7ce5f9e..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. @@ -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`), 0 + // included. Absent when the document carries no date. + if contender.has_created_at { + contenderDict["createdAt"] = contender.created_at + } contenders.append(contenderDict) } @@ -1364,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. @@ -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`), 0 + // included. Absent when the document carries no 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..b853288bee4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DPNSContest.swift @@ -106,18 +106,34 @@ 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. public let displayLabel: String? + /// When this contender submitted its request: its `domain` document's + /// `$createdAt`. + /// + /// 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 } - 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..e722b3f1c75 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(fromSentinelMilliseconds: info.end_time), hasWinner: info.has_winner, abstainVotes: info.abstain_votes, lockVotes: info.lock_votes, @@ -255,6 +246,50 @@ 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 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 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 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 + } + + /// 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(), + !CFNumberIsFloatType(number), + number.int64Value >= 0 else { return nil } + return date(milliseconds: number.uint64Value) + } + // MARK: - One contest's vote state /// Full vote state for a single contest: contenders with tallies, the @@ -348,10 +383,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 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 /// either is present the poll has finished (see ``DPNSContestOutcome``). @@ -366,10 +402,13 @@ 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 } - 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..050a3722661 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DPNSContestDecoderTests.swift @@ -285,6 +285,82 @@ 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 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 + 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) + 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 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) + + XCTAssertNil(SDK.contender(from: contender)) + } + // MARK: - Vote poll coordinates func testIndexValuesPutTheParentDomainFirst() {