From b3c867decd6c682d9ac6f96e8efd3b17591c598e Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:19:02 -0500 Subject: [PATCH 1/6] feat(sdk): add client-side v1 document-query wire decoders Copies the wire-proto -> drive-type decoders for the v1 getDocuments surface from rs-drive-abci's query/document_query/v1/conversions.rs into dash-platform-queries::documents::proto_conversions, verbatim except for a neutral DecodeError replacing the server's QueryError with the exact same message strings. The server is untouched. This is a client-side mirror kept in lockstep by doc contract, the same convention the v0 path uses where CBOR clause decoding mirrors query_documents_v0. Hosting a single shared decode crate that both sides consume is proposed separately; this PR deliberately avoids adding any drive-abci dependency. Upcoming client-side wire decoding (DocumentQuery::try_from_request) consumes these functions; until that commit lands the module carries a temporary allow(dead_code). --- .../src/documents/mod.rs | 5 + .../src/documents/proto_conversions.rs | 377 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 packages/dash-platform-queries/src/documents/proto_conversions.rs diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index 65cde6af08..6c3d07d6b1 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -11,5 +11,10 @@ pub mod document_split_counts; pub mod document_split_sums; pub mod document_sum; pub(crate) mod having_proof_helpers; +/// Client-side wire-proto → drive-type decoders for `getDocuments`, +/// mirroring rs-drive-abci's server request decode; the two must be +/// kept in lockstep (see the module docs). +#[allow(dead_code)] // consumer (`DocumentQuery::try_from_request`) lands next +pub(crate) mod proto_conversions; pub(crate) mod ranked_proof_helpers; pub(crate) mod sum_proof_helpers; diff --git a/packages/dash-platform-queries/src/documents/proto_conversions.rs b/packages/dash-platform-queries/src/documents/proto_conversions.rs new file mode 100644 index 0000000000..2328310f26 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/proto_conversions.rs @@ -0,0 +1,377 @@ +//! Wire-protobuf → drive type conversions for the `getDocuments` +//! query surface, used by +//! [`DocumentQuery::try_from_request`](super::document_query::DocumentQuery::try_from_request) +//! to rebuild the rich query from the wire request so a proved +//! response can be verified against exactly what was asked. +//! +//! This is a client-side **mirror** of the server's decode — +//! rs-drive-abci's `query/document_query/v1/conversions.rs` — and +//! must be kept in lockstep with it, exactly as the v0 path's CBOR +//! clause decoding mirrors `query_documents_v0`. The bytes the +//! server decodes and the bytes the verifier decodes must agree +//! clause-for-clause, or a proof could verify against a different +//! query than the server answered. Function bodies and error +//! message strings are copied verbatim from the server; any change +//! on either side must be replayed on the other. +//! +//! Conversion contract: +//! - Every fallible case maps to [`DecodeError::InvalidArgument`] +//! (malformed wire input, **not** future capability), except the +//! aggregate `ORDER BY` target which maps to +//! [`DecodeError::Unsupported`] (valid request shape, server +//! capability not yet wired). These mirror the server's +//! `QueryError::InvalidArgument` / `QuerySyntaxError::Unsupported` +//! respectively, preserving its error surface. +//! - Conversion is schema-agnostic. `DocumentFieldValue` variants +//! map 1:1 to `dpp::platform_value::Value` variants without +//! consulting the document type's schema. The schema-driven +//! coercion (`document_type.serialize_value_for_key`) runs +//! downstream as it does for the CBOR-shaped v0 path — a `text` +//! variant against an identifier field decodes via base58, a +//! `bytes_value` against the same field decodes as raw 32-byte +//! identifier, and so on. The wire layer just names the +//! primitive; the schema decides the indexed type. + +use dapi_grpc::platform::v0::get_documents_request::{ + document_field_value, + get_documents_request_v1::{select, Select as ProtoSelect}, + having_aggregate, having_clause, order_clause, DocumentFieldValue as ProtoDocumentFieldValue, + HavingAggregate as ProtoHavingAggregate, HavingClause as ProtoHavingClause, + OrderClause as ProtoOrderClause, WhereClause as ProtoWhereClause, + WhereOperator as ProtoWhereOperator, +}; +use dpp::platform_value::Value; +use drive::query::{ + HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, + OrderClause, SelectFunction, SelectProjection, WhereClause, WhereOperator, +}; + +/// Neutral decode error for the proto → drive conversions. +/// +/// Mirrors the two error shapes the server's decode produces +/// (`QueryError::InvalidArgument` and `QuerySyntaxError::Unsupported`) +/// without depending on them; the client-side `DocumentQuery` +/// decoding maps it onto the crate [`Error`](crate::error::Error). +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + /// Malformed wire input — bad discriminant, missing oneof arm, + /// over-deep list nesting. No future protocol version would make + /// this input valid. + #[error("{0}")] + InvalidArgument(String), + /// Well-formed wire input naming a capability the decode target + /// cannot represent yet (e.g. `ORDER BY` on an aggregate key). + /// The wording signals future capability, not malformed request. + #[error("{0}")] + Unsupported(String), +} + +/// Map a wire-level [`ProtoWhereOperator`] discriminant onto +/// drive's [`WhereOperator`]. Unknown discriminants are wire-level +/// garbage (no future protocol value would map a malformed integer +/// to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +pub(crate) fn where_operator_from_proto(op: i32) -> Result { + let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown WhereOperator discriminant: {} (valid values: 0..=10, see \ + `get_documents_request::WhereOperator`)", + op + )) + })?; + Ok(match proto_op { + ProtoWhereOperator::Equal => WhereOperator::Equal, + ProtoWhereOperator::GreaterThan => WhereOperator::GreaterThan, + ProtoWhereOperator::GreaterThanOrEquals => WhereOperator::GreaterThanOrEquals, + ProtoWhereOperator::LessThan => WhereOperator::LessThan, + ProtoWhereOperator::LessThanOrEquals => WhereOperator::LessThanOrEquals, + ProtoWhereOperator::Between => WhereOperator::Between, + ProtoWhereOperator::BetweenExcludeBounds => WhereOperator::BetweenExcludeBounds, + ProtoWhereOperator::BetweenExcludeLeft => WhereOperator::BetweenExcludeLeft, + ProtoWhereOperator::BetweenExcludeRight => WhereOperator::BetweenExcludeRight, + ProtoWhereOperator::In => WhereOperator::In, + ProtoWhereOperator::StartsWith => WhereOperator::StartsWith, + }) +} + +/// Map a wire [`ProtoDocumentFieldValue`] onto a +/// `dpp::platform_value::Value`. Schema-agnostic — variants map +/// 1:1 by primitive type and recurse for `list` up to a depth of +/// 1 (the only nesting level the query surface needs: `IN` / +/// `BETWEEN*` take a flat list of scalars). Anything deeper is +/// rejected as malformed wire input rather than recursed into, +/// so a hostile client can't blow the call stack with +/// `list(list(list(...)))` before schema validation. +/// +/// `None` (oneof unset on the wire) is rejected — a where-clause +/// operand is always concrete; empty where-clauses are expressed +/// by an empty `where_clauses` field at the request level, not by +/// sending an empty `DocumentFieldValue`. +pub(crate) fn value_from_proto(value: ProtoDocumentFieldValue) -> Result { + value_from_proto_at_depth(value, 0) +} + +/// Recursion-bounded form of [`value_from_proto`]. `depth = 0` is +/// the request-level operand; the only legal child shape is a +/// flat list (`depth = 1` for `IN` / `BETWEEN*` candidates), so a +/// `list` encountered at `depth >= 1` is wire-malformed. +fn value_from_proto_at_depth( + value: ProtoDocumentFieldValue, + depth: u8, +) -> Result { + let variant = value.variant.ok_or_else(|| { + DecodeError::InvalidArgument( + "DocumentFieldValue has no variant set; a where-clause operand must \ + be a concrete value" + .to_string(), + ) + })?; + Ok(match variant { + document_field_value::Variant::BoolValue(b) => Value::Bool(b), + document_field_value::Variant::Int64Value(i) => Value::I64(i), + document_field_value::Variant::Uint64Value(u) => Value::U64(u), + document_field_value::Variant::DoubleValue(f) => Value::Float(f), + document_field_value::Variant::Text(s) => Value::Text(s), + document_field_value::Variant::BytesValue(b) => Value::Bytes(b), + document_field_value::Variant::List(list) => { + if depth >= 1 { + return Err(DecodeError::InvalidArgument( + "nested DocumentFieldValue.list is not supported; the v1 \ + query surface accepts at most one level of nesting \ + (`IN` / `BETWEEN*` candidate lists of scalars)" + .to_string(), + )); + } + Value::Array( + list.values + .into_iter() + .map(|v| value_from_proto_at_depth(v, depth + 1)) + .collect::, _>>()?, + ) + } + // The bool payload is a placeholder — picking the + // `null_value` variant means "this operand is null" and + // the bool itself is ignored. See the proto-side comment + // on the field for the rationale. + document_field_value::Variant::NullValue(_) => Value::Null, + }) +} + +/// Map a wire [`ProtoWhereClause`] onto drive's structured +/// [`WhereClause`]. Errors surface as +/// [`DecodeError::InvalidArgument`] for both operator-discriminant +/// and value-shape failures. +pub(crate) fn where_clause_from_proto( + clause: ProtoWhereClause, +) -> Result { + let operator = where_operator_from_proto(clause.operator)?; + let value = clause.value.ok_or_else(|| { + DecodeError::InvalidArgument(format!( + "WhereClause on field '{}' has no value set; every clause must carry a \ + concrete `DocumentFieldValue`", + clause.field + )) + })?; + let value = value_from_proto(value)?; + Ok(WhereClause { + field: clause.field, + operator, + value, + }) +} + +/// Plural form of `where_clause_from_proto` for the request-level +/// `repeated WhereClause` field. Returns an error on the first +/// malformed clause. +pub fn where_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(where_clause_from_proto).collect() +} + +/// Map a wire [`ProtoOrderClause`] onto drive's [`OrderClause`]. +/// +/// The `target` oneof currently has two variants on the wire: +/// `field` (plain column name — evaluated today) and `aggregate` +/// (aggregate function applied to a field — wire-only, rejected +/// with [`DecodeError::Unsupported`]). Unset (`None`) is rejected +/// as malformed wire input. +pub(crate) fn order_clause_from_proto( + clause: ProtoOrderClause, +) -> Result { + let ascending = clause.ascending; + match clause.target { + Some(order_clause::Target::Field(field)) => Ok(OrderClause { field, ascending }), + Some(order_clause::Target::Aggregate(_)) => Err(DecodeError::Unsupported( + "ORDER BY on aggregate keys is not yet implemented".to_string(), + )), + None => Err(DecodeError::InvalidArgument( + "OrderClause has no target set; every clause must carry either a \ + `field` (plain column name) or an `aggregate` (aggregate-function \ + ordering target)" + .to_string(), + )), + } +} + +/// Plural form of `order_clause_from_proto` for the request-level +/// `repeated OrderClause` field. Returns the first error +/// encountered. +pub fn order_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(order_clause_from_proto).collect() +} + +/// Map a wire [`having_aggregate::Function`] discriminant onto +/// drive's [`HavingAggregateFunction`]. Unknown discriminants are +/// wire-level garbage (no future protocol value would map a +/// malformed integer to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +fn having_function_from_proto(function: i32) -> Result { + let proto = having_aggregate::Function::try_from(function).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown HavingAggregate.Function discriminant: {} (valid values: 0..=2, see \ + `get_documents_request::having_aggregate::Function`)", + function + )) + })?; + Ok(match proto { + having_aggregate::Function::Count => HavingAggregateFunction::Count, + having_aggregate::Function::Sum => HavingAggregateFunction::Sum, + having_aggregate::Function::Avg => HavingAggregateFunction::Avg, + }) +} + +/// Map a wire [`having_clause::Operator`] discriminant onto +/// drive's [`HavingOperator`]. Same error contract as +/// [`having_function_from_proto`]. +fn having_operator_from_proto(operator: i32) -> Result { + let proto = having_clause::Operator::try_from(operator).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown HavingClause.Operator discriminant: {} (valid values: 0..=10, see \ + `get_documents_request::having_clause::Operator`)", + operator + )) + })?; + Ok(match proto { + having_clause::Operator::Equal => HavingOperator::Equal, + having_clause::Operator::NotEqual => HavingOperator::NotEqual, + having_clause::Operator::GreaterThan => HavingOperator::GreaterThan, + having_clause::Operator::GreaterThanOrEquals => HavingOperator::GreaterThanOrEquals, + having_clause::Operator::LessThan => HavingOperator::LessThan, + having_clause::Operator::LessThanOrEquals => HavingOperator::LessThanOrEquals, + having_clause::Operator::Between => HavingOperator::Between, + having_clause::Operator::BetweenExcludeBounds => HavingOperator::BetweenExcludeBounds, + having_clause::Operator::BetweenExcludeLeft => HavingOperator::BetweenExcludeLeft, + having_clause::Operator::BetweenExcludeRight => HavingOperator::BetweenExcludeRight, + having_clause::Operator::In => HavingOperator::In, + }) +} + +/// Map a wire [`ProtoHavingAggregate`] onto drive's +/// [`HavingAggregate`]. The aggregate-function ↔ field +/// consistency check (`field` required for everything except +/// `Count`) runs inside the evaluator when HAVING execution +/// lands; the converter only enforces that the proto shape is +/// well-formed. +fn having_aggregate_from_proto( + aggregate: ProtoHavingAggregate, +) -> Result { + Ok(HavingAggregate { + function: having_function_from_proto(aggregate.function)?, + field: aggregate.field, + }) +} + +/// Map a wire [`ProtoHavingClause`] onto drive's structured +/// [`HavingClause`]. Errors surface as +/// [`DecodeError::InvalidArgument`] for any wire-level +/// malformation: unknown discriminant on the aggregate function or +/// operator; missing aggregate; missing right operand (oneof unset +/// on the wire); inner value-shape failures on the literal-value +/// branch. +/// +/// `HAVING` is a boolean per-group predicate and nothing else, so the +/// wire's `right` oneof has exactly one arm and this function has +/// exactly one thing to decode. Cross-group ranking is expressed with +/// SQL's own ordering surface — `ORDER BY DESC +/// LIMIT n [OFFSET m]` — which arrives as an `OrderClause` and never +/// reaches here. +pub(crate) fn having_clause_from_proto( + clause: ProtoHavingClause, +) -> Result { + let aggregate = clause.aggregate.ok_or_else(|| { + DecodeError::InvalidArgument( + "HavingClause has no aggregate set; every clause must carry an \ + aggregate function + field operand" + .to_string(), + ) + })?; + let aggregate = having_aggregate_from_proto(aggregate)?; + let operator = having_operator_from_proto(clause.operator)?; + let right = clause.right.ok_or_else(|| { + DecodeError::InvalidArgument( + "HavingClause has no right operand set; every clause must carry a \ + concrete `DocumentFieldValue` (`right.value`)" + .to_string(), + ) + })?; + let right = match right { + having_clause::Right::Value(v) => HavingRightOperand::Value(value_from_proto(v)?), + }; + Ok(HavingClause { + aggregate, + operator, + right, + }) +} + +/// Plural form of `having_clause_from_proto` for the request- +/// level `repeated HavingClause` field. Returns an error on the +/// first malformed clause. +pub fn having_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(having_clause_from_proto).collect() +} + +/// Map a wire [`select::Function`] discriminant onto drive's +/// [`SelectFunction`]. Unknown discriminants are wire-level +/// garbage (no future protocol value would map a malformed +/// integer to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +fn select_function_from_proto(function: i32) -> Result { + let proto = select::Function::try_from(function).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown Select.Function discriminant: {} (valid values: 0..=5, see \ + `get_documents_request::get_documents_request_v1::select::Function`)", + function + )) + })?; + Ok(match proto { + select::Function::Documents => SelectFunction::Documents, + select::Function::Count => SelectFunction::Count, + select::Function::Sum => SelectFunction::Sum, + select::Function::Avg => SelectFunction::Avg, + select::Function::Min => SelectFunction::Min, + select::Function::Max => SelectFunction::Max, + }) +} + +/// Map a wire [`ProtoSelect`] onto drive's [`SelectProjection`]. +/// An unset `select` field on the request decodes as the proto- +/// default `Select { function: DOCUMENTS, field: "" }`, which +/// maps to [`SelectProjection::documents()`] — keeps callers that +/// don't set the field on the v0-style document-fetch path. +/// +/// Per-function field constraints (e.g. `DOCUMENTS` must have +/// empty `field`, `SUM`/`AVG` require non-empty) are checked at +/// routing time by the server's `validate_and_route`, not here, so +/// the converter only enforces well-formed proto. +pub fn select_from_proto(select: ProtoSelect) -> Result { + Ok(SelectProjection { + function: select_function_from_proto(select.function)?, + field: select.field, + }) +} From d3f66179601f999f32fb2d6ebceca01698884fd1 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:20:37 -0500 Subject: [PATCH 2/6] refactor(sdk): split consensus label validation from client username policy is_consensus_valid_label matches exactly the DPNS contract's label schema pattern (consecutive hyphens allowed); is_valid_username is recomposed as that pattern plus the stricter client-side consecutive-hyphen rejection. Its acceptance set is unchanged - the pre-existing test vectors pass as-is - but the consensus check is now available on its own so document builders cannot reject labels the contract accepts. --- .../src/dpns_usernames.rs | 66 ++++++++----------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index 3f452519b2..bb00156032 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -18,15 +18,34 @@ pub fn convert_to_homograph_safe_chars(input: &str) -> String { .collect() } -/// Check if a username is valid according to DPNS rules -/// -/// A username is valid if: -/// - It's between 3 and 63 characters long -/// - It starts and ends with alphanumeric characters (a-zA-Z0-9) -/// - It contains only alphanumeric characters and hyphens -/// - It doesn't have consecutive hyphens (enforced by the pattern) +/// Check whether a label satisfies the DPNS contract's `label` schema +/// pattern — exactly what consensus enforces, nothing stricter. /// /// Pattern: `^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$` +/// (3-63 characters, alphanumeric and hyphens, alphanumeric at both ends; +/// consecutive hyphens ARE allowed by consensus). +pub fn is_consensus_valid_label(label: &str) -> bool { + if label.len() < 3 || label.len() > 63 { + return false; + } + let chars: Vec = label.chars().collect(); + if !chars[0].is_ascii_alphanumeric() || !chars[chars.len() - 1].is_ascii_alphanumeric() { + return false; + } + chars[1..chars.len() - 1] + .iter() + .all(|&ch| ch.is_ascii_alphanumeric() || ch == '-') +} + +/// Check if a username is valid according to this crate's recommended +/// client-side policy: the consensus pattern plus a stricter rejection of +/// consecutive hyphens. +/// +/// This is deliberately narrower than [`is_consensus_valid_label`] — a name +/// like `ab--cd` is consensus-valid but rejected here, matching the +/// pre-existing policy of the mobile SDK FFI and wasm-sdk gates. Callers +/// that must accept every consensus-valid label should use +/// [`is_consensus_valid_label`] instead. /// /// # Arguments /// @@ -36,38 +55,7 @@ pub fn convert_to_homograph_safe_chars(input: &str) -> String { /// /// Returns `true` if the username is valid, `false` otherwise pub fn is_valid_username(label: &str) -> bool { - // Check length - if label.len() < 3 || label.len() > 63 { - return false; - } - - let chars: Vec = label.chars().collect(); - - // Check first character (must be alphanumeric) - if !chars[0].is_ascii_alphanumeric() { - return false; - } - - // Check last character (must be alphanumeric) - if !chars[chars.len() - 1].is_ascii_alphanumeric() { - return false; - } - - // Check middle characters (can be alphanumeric or hyphen) - for &ch in &chars[1..chars.len() - 1] { - if !ch.is_ascii_alphanumeric() && ch != '-' { - return false; - } - } - - // Additional check: no consecutive hyphens (good practice) - for i in 0..chars.len() - 1 { - if chars[i] == '-' && chars[i + 1] == '-' { - return false; - } - } - - true + is_consensus_valid_label(label) && !label.contains("--") } /// Check if a username is contested (requires masternode voting) From 9d15113d8d1da917c5318436122b81d21dd00676 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:25:14 -0500 Subject: [PATCH 3/6] refactor(sdk): separate DPNS and DashPay document assembly from networked flows register_dpns_name and create_contact_request were interleaving document assembly (id derivation, salted-domain-hash commitment, property maps, size validation) with fetching, ECDH, and broadcasting. The assembly halves become pure functions - build_dpns_preorder_and_domain_documents and build_contact_request_document - that take caller-supplied entropy/salt/ciphertexts and touch no network or randomness. The networked flows now call them; ids, properties, size-validation bounds, and error messages are unchanged. One addition beyond the extraction: the DPNS builder validates the label against the consensus pattern (is_consensus_valid_label) before assembling. The previous flow did no label validation locally and let the network reject invalid labels; failing locally with a clear message is strictly earlier, and using the consensus pattern (not the stricter client policy) means the builder cannot reject labels the contract accepts. --- .../src/platform/dashpay/contact_request.rs | 234 ++++++++++------ packages/rs-sdk/src/platform/dashpay/mod.rs | 8 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 251 ++++++++++++------ 3 files changed, 325 insertions(+), 168 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index d595faaaed..e9b10d7777 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -23,6 +23,134 @@ use platform_encryption::{ }; use std::collections::BTreeMap; +use dpp::data_contract::DataContract; + +/// Already-derived crypto material and metadata for a DIP-15 +/// `contactRequest` document. +/// +/// Everything here is plain data: the ECDH/encryption that produced +/// `encrypted_public_key` and `encrypted_account_label`, and the randomness +/// that produced `entropy`, happen in the caller. +#[derive(Debug, Clone)] +pub struct ContactRequestDocumentParams { + /// The sender's identity id (the document owner) + pub sender_id: Identifier, + /// The recipient's identity id (`toUserId`) + pub recipient_id: Identifier, + /// The sender's encryption key index used for ECDH + pub sender_key_index: u32, + /// The recipient's key index used for ECDH + pub recipient_key_index: u32, + /// Reference to the DashPay receiving account + pub account_reference: u32, + /// ECDH-encrypted extended public key: exactly 96 bytes + /// (16-byte IV + 80 bytes of encrypted DIP-15 compact xpub) + pub encrypted_public_key: Vec, + /// Optional encrypted account label: 48-80 bytes + /// (16-byte IV + 32-64 bytes of encrypted data) + pub encrypted_account_label: Option>, + /// Optional auto-accept proof (38-102 bytes) - not encrypted + pub auto_accept_proof: Option>, + /// The entropy that derives the document id; the same entropy must be + /// attached to the create transition, or platform consensus rejects it + /// with `InvalidDocumentTransitionIdError`. + pub entropy: [u8; 32], +} + +/// Validate the size of a DIP-15 `autoAcceptProof` (38-102 bytes). +pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { + if proof.len() < 38 || proof.len() > 102 { + return Err(Error::Generic(format!( + "autoAcceptProof must be 38-102 bytes, got {}", + proof.len() + ))); + } + Ok(()) +} + +/// Build the id and property map of a DIP-15 `contactRequest` document from +/// already-derived crypto material. +/// +/// This is the pure document-assembly half of [`Sdk::create_contact_request`]: +/// the document id derives from `params.entropy`, and the property map +/// carries exactly the fields the DashPay contract defines (`toUserId`, +/// `encryptedPublicKey`, `senderKeyIndex`, `recipientKeyIndex`, +/// `accountReference`, plus the optional `encryptedAccountLabel` and +/// `autoAcceptProof`). +/// +/// Returns `(document_id, properties)`. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result<(Identifier, BTreeMap), Error> { + if let Some(ref proof) = params.auto_accept_proof { + validate_auto_accept_proof(proof)?; + } + + // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) + if params.encrypted_public_key.len() != 96 { + return Err(Error::Generic(format!( + "Encrypted public key size mismatch: expected 96 bytes, got {}", + params.encrypted_public_key.len() + ))); + } + + // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) + if let Some(ref label) = params.encrypted_account_label { + if label.len() < 48 || label.len() > 80 { + return Err(Error::Generic(format!( + "Encrypted account label size out of range: expected 48-80 bytes, got {}", + label.len() + ))); + } + } + + let contact_request_document_type = + contract + .document_type_for_name("contactRequest") + .map_err(|_| { + Error::Generic("DashPay contactRequest document type not found".to_string()) + })?; + + let document_id = Document::generate_document_id_v0( + &contract.id(), + ¶ms.sender_id, + contact_request_document_type.name(), + params.entropy.as_slice(), + ); + + let mut properties = BTreeMap::new(); + properties.insert( + "toUserId".to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ); + properties.insert( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ); + properties.insert( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ); + properties.insert( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ); + properties.insert( + "accountReference".to_string(), + Value::U32(params.account_reference), + ); + + if let Some(label) = params.encrypted_account_label { + properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); + } + if let Some(proof) = params.auto_accept_proof { + properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); + } + + Ok((document_id, properties)) +} + /// ECDH provider for contact request encryption /// /// Supports two modes: @@ -259,14 +387,11 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided + // Validate auto accept proof size if provided. The builder + // validates again, but checking here first keeps the failure local — + // before the recipient fetch and ECDH work below. if let Some(ref proof) = input.auto_accept_proof { - if proof.len() < 38 || proof.len() > 102 { - return Err(Error::Generic(format!( - "autoAcceptProof must be 38-102 bytes, got {}", - proof.len() - ))); - } + validate_auto_accept_proof(proof)?; } // Fetch recipient identity if only ID was provided @@ -362,90 +487,45 @@ impl Sdk { let mut xpub_iv = [0u8; 16]; rng.fill_bytes(&mut xpub_iv); - // Encrypt the extended public key (includes IV prepended) + // Encrypt the extended public key (includes IV prepended). The + // builder rejects any ciphertext that isn't exactly 96 bytes + // (16-byte IV + 80-byte encrypted data). let encrypted_public_key = encrypt_extended_public_key(&shared_key, &xpub_iv, &extended_public_key); - // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) - if encrypted_public_key.len() != 96 { - return Err(Error::Generic(format!( - "Encrypted public key size mismatch: expected 96 bytes, got {}", - encrypted_public_key.len() - ))); - } - - // Encrypt the account label if provided (includes IV prepended) - let encrypted_account_label = if let Some(ref label) = input.account_label { + // Encrypt the account label if provided (includes IV prepended). The + // builder rejects any ciphertext outside 48-80 bytes + // (16-byte IV + 32-64 byte encrypted data). + let encrypted_account_label = input.account_label.as_ref().map(|label| { let mut label_iv = [0u8; 16]; rng.fill_bytes(&mut label_iv); - let encrypted = encrypt_account_label(&shared_key, &label_iv, label); - - // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) - if encrypted.len() < 48 || encrypted.len() > 80 { - return Err(Error::Generic(format!( - "Encrypted account label size out of range: expected 48-80 bytes, got {}", - encrypted.len() - ))); - } - Some(encrypted) - } else { - None - }; + encrypt_account_label(&shared_key, &label_iv, label) + }); // Fetch DashPay contract let dashpay_contract = self.fetch_dashpay_contract().await?; - // Get contactRequest document type - let contact_request_document_type = dashpay_contract - .document_type_for_name("contactRequest") - .map_err(|_| { - Error::Generic("DashPay contactRequest document type not found".to_string()) - })?; - // Generate entropy for document ID let mut rng = StdRng::from_entropy(); let entropy = Bytes32::random_with_rng(&mut rng); - // Generate document ID + // Assemble the document in the pure builder above, keeping document + // assembly separate from this networked flow. let sender_id = input.sender_identity.id().to_owned(); - let document_id = Document::generate_document_id_v0( - &dashpay_contract.id(), - &sender_id, - contact_request_document_type.name(), - entropy.as_slice(), - ); - - // Build document properties - let mut properties = BTreeMap::new(); - let recipient_id = recipient_identity.id().to_owned(); - properties.insert( - "toUserId".to_string(), - Value::Identifier(recipient_id.to_buffer()), - ); - properties.insert( - "encryptedPublicKey".to_string(), - Value::Bytes(encrypted_public_key), - ); - properties.insert( - "senderKeyIndex".to_string(), - Value::U32(input.sender_key_index), - ); - properties.insert( - "recipientKeyIndex".to_string(), - Value::U32(input.recipient_key_index), - ); - properties.insert( - "accountReference".to_string(), - Value::U32(input.account_reference), - ); - - // Add optional fields - if let Some(label) = encrypted_account_label { - properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); - } - if let Some(proof) = input.auto_accept_proof { - properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); - } + let (document_id, properties) = build_contact_request_document( + &dashpay_contract, + ContactRequestDocumentParams { + sender_id, + recipient_id: recipient_identity.id().to_owned(), + sender_key_index: input.sender_key_index, + recipient_key_index: input.recipient_key_index, + account_reference: input.account_reference, + encrypted_public_key, + encrypted_account_label, + auto_accept_proof: input.auto_accept_proof, + entropy: entropy.0, + }, + )?; // Return the essential fields for the contact request, including the // entropy that derived `document_id` so the broadcast path can reuse it. diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854..1991150d64 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -7,9 +7,11 @@ mod contact_request; mod contact_request_queries; pub use contact_request::{ - recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, - sender_key_purpose_is_acceptable_on_receive, ContactRequestInput, ContactRequestResult, - EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, + build_contact_request_document, recipient_key_purpose_is_acceptable_on_receive, + recipient_key_purpose_is_valid, sender_key_purpose_is_acceptable_on_receive, + validate_auto_accept_proof, ContactRequestDocumentParams, ContactRequestInput, + ContactRequestResult, EcdhProvider, RecipientIdentity, SendContactRequestInput, + SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 6de3e2950d..eacec64377 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -11,10 +11,12 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::{Document, Fetch, FetchMany}; use crate::{Error, Sdk}; use dash_context_provider::ContextProvider; +use dash_platform_queries::dpns_usernames::is_consensus_valid_label; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::DataContract; use dpp::document::{DocumentV0, DocumentV0Getters}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::signer::Signer; @@ -53,6 +55,157 @@ fn hash_double(data: Vec) -> [u8; 32] { hash.to_byte_array() } +/// Build the DPNS `preorder` and `domain` documents that register +/// `label`.dash for `identity_id`, exactly as platform consensus expects +/// them. +/// +/// This is the pure document-assembly half of [`Sdk::register_dpns_name`]: +/// no networking, and no randomness — the caller supplies the `entropy` +/// that derives both document ids (the same entropy must later be attached +/// to both create transitions) and the preorder `salt`, whose double-SHA256 +/// over `salt ‖ ".dash"` becomes the preorder's +/// `saltedDomainHash`. +/// +/// The `label` must satisfy [`is_consensus_valid_label`]; the raw label is stored +/// in the domain document's `label` property while its +/// [homograph-safe](convert_to_homograph_safe_chars) form is stored in +/// `normalizedLabel`. +/// +/// # Salt secrecy and reveal order +/// +/// The preorder/domain split is DPNS's front-running protection: the +/// preorder commits to `saltedDomainHash` without revealing which name +/// is being registered, and only the later domain document discloses +/// the `label` and the `preorderSalt` that tie it to the commitment. +/// That protection holds only if the caller upholds what +/// [`Sdk::register_dpns_name`] does automatically: +/// +/// - generate a **fresh 32-byte salt from a CSPRNG** for every +/// registration attempt (the SDK draws it from +/// `StdRng::from_entropy()`). A reused or predictable salt lets an +/// observer precompute `sha256d(salt ‖ ".dash")` for +/// candidate labels and identify — then front-run — the name from +/// the preorder alone; +/// - keep the salt, the label, and the assembled domain document +/// **private until the preorder create transition is confirmed** +/// (the SDK submits the preorder and waits for its response before +/// broadcasting the domain document). Revealing them earlier +/// discloses the name while it is still unclaimed, defeating the +/// commitment. +/// +/// Callers driving their own flow inherit both obligations — this +/// builder takes `salt` as an argument precisely because it has no +/// randomness of its own and cannot enforce either one. +/// +/// Returns `(preorder_document, domain_document)`. +pub fn build_dpns_preorder_and_domain_documents( + contract: &DataContract, + identity_id: Identifier, + label: &str, + entropy: [u8; 32], + salt: [u8; 32], +) -> Result<(Document, Document), Error> { + if !is_consensus_valid_label(label) { + return Err(Error::Generic(format!( + "Invalid DPNS label \"{label}\": must be 3-63 characters, alphanumeric and hyphens \ + only, starting and ending with an alphanumeric character" + ))); + } + + let preorder_document_type = contract + .document_type_for_name("preorder") + .map_err(|_| Error::Generic("DPNS preorder document type not found".to_string()))?; + + let domain_document_type = contract + .document_type_for_name("domain") + .map_err(|_| Error::Generic("DPNS domain document type not found".to_string()))?; + + let preorder_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + preorder_document_type.name(), + entropy.as_slice(), + ); + let domain_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + domain_document_type.name(), + entropy.as_slice(), + ); + + // Create salted domain hash for preorder + let normalized_label = convert_to_homograph_safe_chars(label); + let mut salted_domain_buffer: Vec = vec![]; + salted_domain_buffer.extend(salt); + salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); + let salted_domain_hash = hash_double(salted_domain_buffer); + + let preorder_document = Document::V0(DocumentV0 { + id: preorder_id, + owner_id: identity_id, + properties: BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(salted_domain_hash), + )]), + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let domain_document = Document::V0(DocumentV0 { + id: domain_id, + owner_id: identity_id, + 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(normalized_label)), + ("preorderSalt".to_string(), Value::Bytes32(salt)), + ( + "records".to_string(), + Value::Map(vec![( + Value::Text("identity".to_string()), + Value::Identifier(identity_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]), + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + Ok((preorder_document, domain_document)) +} + /// Callback type for preorder document pub type PreorderCallback = Box; @@ -164,95 +317,17 @@ impl Sdk { let entropy = Bytes32::random_with_rng(&mut rng); let salt: [u8; 32] = rng.gen(); - // Generate document IDs - let identity_id = input.identity.id().to_owned(); - let preorder_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &identity_id, - preorder_document_type.name(), - entropy.as_slice(), - ); - let domain_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &identity_id, - domain_document_type.name(), - entropy.as_slice(), - ); - - // Create salted domain hash for preorder + // Assemble both documents in the pure builder above, keeping + // document assembly separate from this networked flow. + let (preorder_document, domain_document) = build_dpns_preorder_and_domain_documents( + &dpns_contract, + input.identity.id().to_owned(), + &input.label, + entropy.0, + salt, + )?; + let normalized_label = convert_to_homograph_safe_chars(&input.label); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); - let salted_domain_hash = hash_double(salted_domain_buffer); - - // Create preorder document - let preorder_document = Document::V0(DocumentV0 { - id: preorder_id, - owner_id: identity_id, - properties: BTreeMap::from([( - "saltedDomainHash".to_string(), - Value::Bytes32(salted_domain_hash), - )]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); - - // Create domain document - let domain_document = Document::V0(DocumentV0 { - id: domain_id, - owner_id: identity_id, - properties: BTreeMap::from([ - ( - "parentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ( - "normalizedParentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ("label".to_string(), Value::Text(input.label.clone())), - ( - "normalizedLabel".to_string(), - Value::Text(normalized_label.clone()), - ), - ("preorderSalt".to_string(), Value::Bytes32(salt)), - ( - "records".to_string(), - Value::Map(vec![( - Value::Text("identity".to_string()), - Value::Identifier(identity_id.to_buffer()), - )]), - ), - ( - "subdomainRules".to_string(), - Value::Map(vec![( - Value::Text("allowSubdomains".to_string()), - Value::Bool(false), - )]), - ), - ]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); // Submit preorder document first let platform_preorder_document = preorder_document From c02bbcf3c787e87d698efa34dc37591ffaccb87d Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:26:36 -0500 Subject: [PATCH 4/6] refactor(sdk): move pure DPNS and DashPay document builders into dash-platform-queries File move of the pure builders introduced in the previous commit, unchanged except for the error type: they now return dash_platform_queries::Error::InvalidInput, which dash-sdk maps back to Error::Generic with identical messages, so the SDK surface is byte-for-byte the same. rs-sdk re-exports the builders at their previous paths. This makes the document-assembly half of DPNS registration and DashPay contact requests reachable without the SDK's transport stack; crypto material and randomness stay with the caller. --- packages/dash-platform-queries/src/dashpay.rs | 143 +++++++++++++++ .../src/dpns_usernames.rs | 173 +++++++++++++++++- packages/dash-platform-queries/src/error.rs | 6 + packages/dash-platform-queries/src/lib.rs | 1 + packages/rs-sdk/src/error.rs | 3 + .../src/platform/dashpay/contact_request.rs | 142 +------------- packages/rs-sdk/src/platform/dashpay/mod.rs | 11 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 172 +---------------- 8 files changed, 343 insertions(+), 308 deletions(-) create mode 100644 packages/dash-platform-queries/src/dashpay.rs diff --git a/packages/dash-platform-queries/src/dashpay.rs b/packages/dash-platform-queries/src/dashpay.rs new file mode 100644 index 0000000000..37d9bbf5fd --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,143 @@ +//! Transport-free DashPay contact request document assembly. +//! +//! The Sdk-bound DashPay surface (recipient fetching, ECDH, encryption, +//! broadcasting) lives in `dash-sdk`; this module is the pure DIP-15 +//! `contactRequest` document assembly it shares with embedders. All crypto +//! material arrives here as bytes — key derivation and encryption stay with +//! the caller. + +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; + +/// Already-derived crypto material and metadata for a DIP-15 +/// `contactRequest` document. +/// +/// Everything here is plain data: the ECDH/encryption that produced +/// `encrypted_public_key` and `encrypted_account_label`, and the randomness +/// that produced `entropy`, happen in the caller (`dash-sdk` or an +/// embedder). +#[derive(Debug, Clone)] +pub struct ContactRequestDocumentParams { + /// The sender's identity id (the document owner) + pub sender_id: Identifier, + /// The recipient's identity id (`toUserId`) + pub recipient_id: Identifier, + /// The sender's encryption key index used for ECDH + pub sender_key_index: u32, + /// The recipient's key index used for ECDH + pub recipient_key_index: u32, + /// Reference to the DashPay receiving account + pub account_reference: u32, + /// ECDH-encrypted extended public key: exactly 96 bytes + /// (16-byte IV + 80 bytes of encrypted DIP-15 compact xpub) + pub encrypted_public_key: Vec, + /// Optional encrypted account label: 48-80 bytes + /// (16-byte IV + 32-64 bytes of encrypted data) + pub encrypted_account_label: Option>, + /// Optional auto-accept proof (38-102 bytes) - not encrypted + pub auto_accept_proof: Option>, + /// The entropy that derives the document id; the same entropy must be + /// attached to the create transition, or platform consensus rejects it + /// with `InvalidDocumentTransitionIdError`. + pub entropy: [u8; 32], +} + +/// Validate the size of a DIP-15 `autoAcceptProof` (38-102 bytes). +pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { + if proof.len() < 38 || proof.len() > 102 { + return Err(Error::InvalidInput(format!( + "autoAcceptProof must be 38-102 bytes, got {}", + proof.len() + ))); + } + Ok(()) +} + +/// Build the id and property map of a DIP-15 `contactRequest` document from +/// already-derived crypto material. +/// +/// This is the pure document-assembly half of `dash-sdk`'s +/// `create_contact_request`: the document id derives from +/// `params.entropy`, and the property map carries exactly the fields the +/// DashPay contract defines (`toUserId`, `encryptedPublicKey`, +/// `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, plus the +/// optional `encryptedAccountLabel` and `autoAcceptProof`). +/// +/// Returns `(document_id, properties)`. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result<(Identifier, BTreeMap), Error> { + if let Some(ref proof) = params.auto_accept_proof { + validate_auto_accept_proof(proof)?; + } + + // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) + if params.encrypted_public_key.len() != 96 { + return Err(Error::InvalidInput(format!( + "Encrypted public key size mismatch: expected 96 bytes, got {}", + params.encrypted_public_key.len() + ))); + } + + // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) + if let Some(ref label) = params.encrypted_account_label { + if label.len() < 48 || label.len() > 80 { + return Err(Error::InvalidInput(format!( + "Encrypted account label size out of range: expected 48-80 bytes, got {}", + label.len() + ))); + } + } + + let contact_request_document_type = + contract + .document_type_for_name("contactRequest") + .map_err(|_| { + Error::InvalidInput("DashPay contactRequest document type not found".to_string()) + })?; + + let document_id = Document::generate_document_id_v0( + &contract.id(), + ¶ms.sender_id, + contact_request_document_type.name(), + params.entropy.as_slice(), + ); + + let mut properties = BTreeMap::new(); + properties.insert( + "toUserId".to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ); + properties.insert( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ); + properties.insert( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ); + properties.insert( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ); + properties.insert( + "accountReference".to_string(), + Value::U32(params.account_reference), + ); + + if let Some(label) = params.encrypted_account_label { + properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); + } + if let Some(proof) = params.auto_accept_proof { + properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); + } + + Ok((document_id, properties)) +} diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index bb00156032..585ba8235b 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -1,8 +1,177 @@ //! Transport-free DPNS username helpers. //! //! The Sdk-bound DPNS surface (registration, availability checks, name -//! resolution) lives in `dash-sdk`; these free functions are pure string -//! validation/normalization shared with embedders. +//! resolution) lives in `dash-sdk`; the free functions here are the pure +//! pieces shared with embedders: string validation/normalization and the +//! preorder/domain document assembly used to register a name. + +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0}; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; + +/// Hash a buffer twice using SHA256 (double SHA256) +fn hash_double(data: Vec) -> [u8; 32] { + use dpp::dashcore::hashes::{sha256d, Hash}; + // sha256d already does double SHA256 + let hash = sha256d::Hash::hash(&data); + hash.to_byte_array() +} + +/// Build the DPNS `preorder` and `domain` documents that register +/// `label`.dash for `identity_id`, exactly as platform consensus expects +/// them. +/// +/// This is the pure document-assembly half of `dash-sdk`'s +/// `register_dpns_name`: no networking, and no randomness — the caller +/// supplies the `entropy` that derives both document ids (the same entropy +/// must later be attached to both create transitions) and the preorder +/// `salt`, whose double-SHA256 over `salt ‖ ".dash"` +/// becomes the preorder's `saltedDomainHash`. +/// +/// The `label` must satisfy [`is_consensus_valid_label`]; the raw label is stored +/// in the domain document's `label` property while its +/// [homograph-safe](convert_to_homograph_safe_chars) form is stored in +/// `normalizedLabel`. +/// +/// # Salt secrecy and reveal order +/// +/// The preorder/domain split is DPNS's front-running protection: the +/// preorder commits to `saltedDomainHash` without revealing which name +/// is being registered, and only the later domain document discloses +/// the `label` and the `preorderSalt` that tie it to the commitment. +/// That protection holds only if the caller upholds what the networked +/// SDK (`dash-sdk`'s `register_dpns_name`) does automatically: +/// +/// - generate a **fresh 32-byte salt from a CSPRNG** for every +/// registration attempt (the SDK draws it from +/// `StdRng::from_entropy()`). A reused or predictable salt lets an +/// observer precompute `sha256d(salt ‖ ".dash")` for +/// candidate labels and identify — then front-run — the name from +/// the preorder alone; +/// - keep the salt, the label, and the assembled domain document +/// **private until the preorder create transition is confirmed** +/// (the SDK submits the preorder and waits for its response before +/// broadcasting the domain document). Revealing them earlier +/// discloses the name while it is still unclaimed, defeating the +/// commitment. +/// +/// Embedders driving their own transport inherit both obligations — +/// this builder takes `salt` as an argument precisely because it has +/// no randomness of its own and cannot enforce either one. +/// +/// Returns `(preorder_document, domain_document)`. +pub fn build_dpns_preorder_and_domain_documents( + contract: &DataContract, + identity_id: Identifier, + label: &str, + entropy: [u8; 32], + salt: [u8; 32], +) -> Result<(Document, Document), Error> { + if !is_consensus_valid_label(label) { + return Err(Error::InvalidInput(format!( + "Invalid DPNS label \"{label}\": must be 3-63 characters, alphanumeric and hyphens \ + only, starting and ending with an alphanumeric character" + ))); + } + + let preorder_document_type = contract + .document_type_for_name("preorder") + .map_err(|_| Error::InvalidInput("DPNS preorder document type not found".to_string()))?; + + let domain_document_type = contract + .document_type_for_name("domain") + .map_err(|_| Error::InvalidInput("DPNS domain document type not found".to_string()))?; + + let preorder_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + preorder_document_type.name(), + entropy.as_slice(), + ); + let domain_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + domain_document_type.name(), + entropy.as_slice(), + ); + + // Create salted domain hash for preorder + let normalized_label = convert_to_homograph_safe_chars(label); + let mut salted_domain_buffer: Vec = vec![]; + salted_domain_buffer.extend(salt); + salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); + let salted_domain_hash = hash_double(salted_domain_buffer); + + let preorder_document = Document::V0(DocumentV0 { + id: preorder_id, + owner_id: identity_id, + properties: BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(salted_domain_hash), + )]), + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + let domain_document = Document::V0(DocumentV0 { + id: domain_id, + owner_id: identity_id, + 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(normalized_label)), + ("preorderSalt".to_string(), Value::Bytes32(salt)), + ( + "records".to_string(), + Value::Map(vec![( + Value::Text("identity".to_string()), + Value::Identifier(identity_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]), + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + }); + + Ok((preorder_document, domain_document)) +} /// Convert a string to homograph-safe characters by replacing 'o', 'i', and 'l' /// with '0', '1', and '1' respectively to prevent homograph attacks diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs index 0d8727763c..ee42f67b7e 100644 --- a/packages/dash-platform-queries/src/error.rs +++ b/packages/dash-platform-queries/src/error.rs @@ -15,6 +15,12 @@ pub enum Error { /// Query is not configured properly for the target platform version #[error("SDK misconfigured: {0}")] Config(String), + /// Input to a document builder failed validation (bad label, wrong + /// ciphertext length, unknown document type, ...). `dash-sdk` maps this + /// to its `Error::Generic`, preserving the messages these checks + /// produced before they moved here. + #[error("{0}")] + InvalidInput(String), /// Drive error #[error("Drive error: {0}")] Drive(#[from] drive::error::Error), diff --git a/packages/dash-platform-queries/src/lib.rs b/packages/dash-platform-queries/src/lib.rs index 9da47cf643..29b71b8f43 100644 --- a/packages/dash-platform-queries/src/lib.rs +++ b/packages/dash-platform-queries/src/lib.rs @@ -13,6 +13,7 @@ #![allow(clippy::result_large_err)] pub mod block_info_from_metadata; +pub mod dashpay; pub mod documents; pub mod dpns_usernames; pub mod error; diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index cc8309ebcd..89ade69e74 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -137,6 +137,9 @@ impl From for Error { fn from(value: dash_platform_queries::Error) -> Self { match value { dash_platform_queries::Error::Config(msg) => Self::Config(msg), + // Builder input validation moved to the query core keeps surfacing + // as Generic with the exact messages it produced inside this crate. + dash_platform_queries::Error::InvalidInput(msg) => Self::Generic(msg), dash_platform_queries::Error::Drive(e) => Self::Drive(e), dash_platform_queries::Error::Protocol(e) => Self::Protocol(e), } diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index e9b10d7777..fd2aaa395c 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -5,11 +5,13 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::Document; use crate::{Error, Sdk}; +use dash_platform_queries::dashpay::{ + build_contact_request_document, validate_auto_accept_proof, ContactRequestDocumentParams, +}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{RngCore, SeedableRng}; use dpp::dashcore::secp256k1::{PublicKey, SecretKey}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::document::DocumentV0; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -23,134 +25,6 @@ use platform_encryption::{ }; use std::collections::BTreeMap; -use dpp::data_contract::DataContract; - -/// Already-derived crypto material and metadata for a DIP-15 -/// `contactRequest` document. -/// -/// Everything here is plain data: the ECDH/encryption that produced -/// `encrypted_public_key` and `encrypted_account_label`, and the randomness -/// that produced `entropy`, happen in the caller. -#[derive(Debug, Clone)] -pub struct ContactRequestDocumentParams { - /// The sender's identity id (the document owner) - pub sender_id: Identifier, - /// The recipient's identity id (`toUserId`) - pub recipient_id: Identifier, - /// The sender's encryption key index used for ECDH - pub sender_key_index: u32, - /// The recipient's key index used for ECDH - pub recipient_key_index: u32, - /// Reference to the DashPay receiving account - pub account_reference: u32, - /// ECDH-encrypted extended public key: exactly 96 bytes - /// (16-byte IV + 80 bytes of encrypted DIP-15 compact xpub) - pub encrypted_public_key: Vec, - /// Optional encrypted account label: 48-80 bytes - /// (16-byte IV + 32-64 bytes of encrypted data) - pub encrypted_account_label: Option>, - /// Optional auto-accept proof (38-102 bytes) - not encrypted - pub auto_accept_proof: Option>, - /// The entropy that derives the document id; the same entropy must be - /// attached to the create transition, or platform consensus rejects it - /// with `InvalidDocumentTransitionIdError`. - pub entropy: [u8; 32], -} - -/// Validate the size of a DIP-15 `autoAcceptProof` (38-102 bytes). -pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { - if proof.len() < 38 || proof.len() > 102 { - return Err(Error::Generic(format!( - "autoAcceptProof must be 38-102 bytes, got {}", - proof.len() - ))); - } - Ok(()) -} - -/// Build the id and property map of a DIP-15 `contactRequest` document from -/// already-derived crypto material. -/// -/// This is the pure document-assembly half of [`Sdk::create_contact_request`]: -/// the document id derives from `params.entropy`, and the property map -/// carries exactly the fields the DashPay contract defines (`toUserId`, -/// `encryptedPublicKey`, `senderKeyIndex`, `recipientKeyIndex`, -/// `accountReference`, plus the optional `encryptedAccountLabel` and -/// `autoAcceptProof`). -/// -/// Returns `(document_id, properties)`. -pub fn build_contact_request_document( - contract: &DataContract, - params: ContactRequestDocumentParams, -) -> Result<(Identifier, BTreeMap), Error> { - if let Some(ref proof) = params.auto_accept_proof { - validate_auto_accept_proof(proof)?; - } - - // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) - if params.encrypted_public_key.len() != 96 { - return Err(Error::Generic(format!( - "Encrypted public key size mismatch: expected 96 bytes, got {}", - params.encrypted_public_key.len() - ))); - } - - // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) - if let Some(ref label) = params.encrypted_account_label { - if label.len() < 48 || label.len() > 80 { - return Err(Error::Generic(format!( - "Encrypted account label size out of range: expected 48-80 bytes, got {}", - label.len() - ))); - } - } - - let contact_request_document_type = - contract - .document_type_for_name("contactRequest") - .map_err(|_| { - Error::Generic("DashPay contactRequest document type not found".to_string()) - })?; - - let document_id = Document::generate_document_id_v0( - &contract.id(), - ¶ms.sender_id, - contact_request_document_type.name(), - params.entropy.as_slice(), - ); - - let mut properties = BTreeMap::new(); - properties.insert( - "toUserId".to_string(), - Value::Identifier(params.recipient_id.to_buffer()), - ); - properties.insert( - "encryptedPublicKey".to_string(), - Value::Bytes(params.encrypted_public_key), - ); - properties.insert( - "senderKeyIndex".to_string(), - Value::U32(params.sender_key_index), - ); - properties.insert( - "recipientKeyIndex".to_string(), - Value::U32(params.recipient_key_index), - ); - properties.insert( - "accountReference".to_string(), - Value::U32(params.account_reference), - ); - - if let Some(label) = params.encrypted_account_label { - properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); - } - if let Some(proof) = params.auto_accept_proof { - properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); - } - - Ok((document_id, properties)) -} - /// ECDH provider for contact request encryption /// /// Supports two modes: @@ -387,7 +261,7 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided. The builder + // Validate auto accept proof size if provided. The shared builder // validates again, but checking here first keeps the failure local — // before the recipient fetch and ECDH work below. if let Some(ref proof) = input.auto_accept_proof { @@ -487,14 +361,14 @@ impl Sdk { let mut xpub_iv = [0u8; 16]; rng.fill_bytes(&mut xpub_iv); - // Encrypt the extended public key (includes IV prepended). The + // Encrypt the extended public key (includes IV prepended). The shared // builder rejects any ciphertext that isn't exactly 96 bytes // (16-byte IV + 80-byte encrypted data). let encrypted_public_key = encrypt_extended_public_key(&shared_key, &xpub_iv, &extended_public_key); // Encrypt the account label if provided (includes IV prepended). The - // builder rejects any ciphertext outside 48-80 bytes + // shared builder rejects any ciphertext outside 48-80 bytes // (16-byte IV + 32-64 byte encrypted data). let encrypted_account_label = input.account_label.as_ref().map(|label| { let mut label_iv = [0u8; 16]; @@ -509,8 +383,8 @@ impl Sdk { let mut rng = StdRng::from_entropy(); let entropy = Bytes32::random_with_rng(&mut rng); - // Assemble the document in the pure builder above, keeping document - // assembly separate from this networked flow. + // Assemble the document in the shared transport-free builder, so + // networked and embedder flows produce byte-identical documents. let sender_id = input.sender_identity.id().to_owned(); let (document_id, properties) = build_contact_request_document( &dashpay_contract, diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 1991150d64..a9e53b8778 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -7,13 +7,14 @@ mod contact_request; mod contact_request_queries; pub use contact_request::{ - build_contact_request_document, recipient_key_purpose_is_acceptable_on_receive, - recipient_key_purpose_is_valid, sender_key_purpose_is_acceptable_on_receive, - validate_auto_accept_proof, ContactRequestDocumentParams, ContactRequestInput, - ContactRequestResult, EcdhProvider, RecipientIdentity, SendContactRequestInput, - SendContactRequestResult, + recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, + sender_key_purpose_is_acceptable_on_receive, ContactRequestInput, ContactRequestResult, + EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; +pub use dash_platform_queries::dashpay::{ + build_contact_request_document, validate_auto_accept_proof, ContactRequestDocumentParams, +}; use crate::platform::Fetch; use crate::{Error, Sdk}; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index eacec64377..15021d9042 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -3,7 +3,8 @@ mod queries; pub use contested_queries::ContestedDpnsUsername; pub use dash_platform_queries::dpns_usernames::{ - convert_to_homograph_safe_chars, is_contested_username, is_valid_username, + build_dpns_preorder_and_domain_documents, convert_to_homograph_safe_chars, + is_contested_username, is_valid_username, }; pub use queries::DpnsUsername; @@ -11,19 +12,15 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::{Document, Fetch, FetchMany}; use crate::{Error, Sdk}; use dash_context_provider::ContextProvider; -use dash_platform_queries::dpns_usernames::is_consensus_valid_label; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::DataContract; -use dpp::document::{DocumentV0, DocumentV0Getters}; +use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::{Bytes32, Value}; use dpp::prelude::Identifier; -use std::collections::BTreeMap; use std::sync::Arc; fn extract_dpns_label(name: &str) -> &str { @@ -47,165 +44,6 @@ fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } -/// Hash a buffer twice using SHA256 (double SHA256) -fn hash_double(data: Vec) -> [u8; 32] { - use dpp::dashcore::hashes::{sha256d, Hash}; - // sha256d already does double SHA256 - let hash = sha256d::Hash::hash(&data); - hash.to_byte_array() -} - -/// Build the DPNS `preorder` and `domain` documents that register -/// `label`.dash for `identity_id`, exactly as platform consensus expects -/// them. -/// -/// This is the pure document-assembly half of [`Sdk::register_dpns_name`]: -/// no networking, and no randomness — the caller supplies the `entropy` -/// that derives both document ids (the same entropy must later be attached -/// to both create transitions) and the preorder `salt`, whose double-SHA256 -/// over `salt ‖ ".dash"` becomes the preorder's -/// `saltedDomainHash`. -/// -/// The `label` must satisfy [`is_consensus_valid_label`]; the raw label is stored -/// in the domain document's `label` property while its -/// [homograph-safe](convert_to_homograph_safe_chars) form is stored in -/// `normalizedLabel`. -/// -/// # Salt secrecy and reveal order -/// -/// The preorder/domain split is DPNS's front-running protection: the -/// preorder commits to `saltedDomainHash` without revealing which name -/// is being registered, and only the later domain document discloses -/// the `label` and the `preorderSalt` that tie it to the commitment. -/// That protection holds only if the caller upholds what -/// [`Sdk::register_dpns_name`] does automatically: -/// -/// - generate a **fresh 32-byte salt from a CSPRNG** for every -/// registration attempt (the SDK draws it from -/// `StdRng::from_entropy()`). A reused or predictable salt lets an -/// observer precompute `sha256d(salt ‖ ".dash")` for -/// candidate labels and identify — then front-run — the name from -/// the preorder alone; -/// - keep the salt, the label, and the assembled domain document -/// **private until the preorder create transition is confirmed** -/// (the SDK submits the preorder and waits for its response before -/// broadcasting the domain document). Revealing them earlier -/// discloses the name while it is still unclaimed, defeating the -/// commitment. -/// -/// Callers driving their own flow inherit both obligations — this -/// builder takes `salt` as an argument precisely because it has no -/// randomness of its own and cannot enforce either one. -/// -/// Returns `(preorder_document, domain_document)`. -pub fn build_dpns_preorder_and_domain_documents( - contract: &DataContract, - identity_id: Identifier, - label: &str, - entropy: [u8; 32], - salt: [u8; 32], -) -> Result<(Document, Document), Error> { - if !is_consensus_valid_label(label) { - return Err(Error::Generic(format!( - "Invalid DPNS label \"{label}\": must be 3-63 characters, alphanumeric and hyphens \ - only, starting and ending with an alphanumeric character" - ))); - } - - let preorder_document_type = contract - .document_type_for_name("preorder") - .map_err(|_| Error::Generic("DPNS preorder document type not found".to_string()))?; - - let domain_document_type = contract - .document_type_for_name("domain") - .map_err(|_| Error::Generic("DPNS domain document type not found".to_string()))?; - - let preorder_id = Document::generate_document_id_v0( - &contract.id(), - &identity_id, - preorder_document_type.name(), - entropy.as_slice(), - ); - let domain_id = Document::generate_document_id_v0( - &contract.id(), - &identity_id, - domain_document_type.name(), - entropy.as_slice(), - ); - - // Create salted domain hash for preorder - let normalized_label = convert_to_homograph_safe_chars(label); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); - let salted_domain_hash = hash_double(salted_domain_buffer); - - let preorder_document = Document::V0(DocumentV0 { - id: preorder_id, - owner_id: identity_id, - properties: BTreeMap::from([( - "saltedDomainHash".to_string(), - Value::Bytes32(salted_domain_hash), - )]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); - - let domain_document = Document::V0(DocumentV0 { - id: domain_id, - owner_id: identity_id, - 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(normalized_label)), - ("preorderSalt".to_string(), Value::Bytes32(salt)), - ( - "records".to_string(), - Value::Map(vec![( - Value::Text("identity".to_string()), - Value::Identifier(identity_id.to_buffer()), - )]), - ), - ( - "subdomainRules".to_string(), - Value::Map(vec![( - Value::Text("allowSubdomains".to_string()), - Value::Bool(false), - )]), - ), - ]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); - - Ok((preorder_document, domain_document)) -} - /// Callback type for preorder document pub type PreorderCallback = Box; @@ -317,8 +155,8 @@ impl Sdk { let entropy = Bytes32::random_with_rng(&mut rng); let salt: [u8; 32] = rng.gen(); - // Assemble both documents in the pure builder above, keeping - // document assembly separate from this networked flow. + // Assemble both documents in the shared transport-free builder, so + // networked and embedder flows produce byte-identical documents. let (preorder_document, domain_document) = build_dpns_preorder_and_domain_documents( &dpns_contract, input.identity.id().to_owned(), From f778c9b05e00c75a365930ae18225955e17c4131 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:28:31 -0500 Subject: [PATCH 5/6] refactor(sdk): decode document queries from the wire request in shared client code DocumentQuery::try_from_request decodes a wire GetDocumentsRequest back into a rich DocumentQuery - the inverse of request encoding. V1 typed clauses go through the same proto_conversions functions the server's v1 handler runs; V0 CBOR where/order_by fields are decoded exactly as the server's query_documents_v0 does. Multi-projection selects and limit Some(0) are rejected, mirroring the server's contracts. --- Cargo.lock | 1 + packages/dash-platform-queries/Cargo.toml | 1 + .../src/documents/document_query.rs | 267 ++++++++++++++++++ .../src/documents/mod.rs | 7 +- packages/dash-platform-queries/src/error.rs | 16 ++ 5 files changed, 289 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92026cb5b5..f0a20a6cd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1706,6 +1706,7 @@ dependencies = [ name = "dash-platform-queries" version = "4.2.0-dev.1" dependencies = [ + "ciborium", "dapi-grpc", "dash-context-provider", "dash-platform-macros", diff --git a/packages/dash-platform-queries/Cargo.toml b/packages/dash-platform-queries/Cargo.toml index f3d76e7c74..7c9870a739 100644 --- a/packages/dash-platform-queries/Cargo.toml +++ b/packages/dash-platform-queries/Cargo.toml @@ -17,6 +17,7 @@ mocks = [ ] [dependencies] +ciborium = { version = "0.2.2" } dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ "platform", "client", diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 113e6e1e47..856fa4fa1e 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use super::proto_conversions; use crate::error::Error; use dapi_grpc::platform::v0::get_documents_request::Version::{V0, V1}; use dapi_grpc::platform::v0::{ @@ -383,6 +384,272 @@ impl DocumentQuery { ) -> Result { GetDocumentsRequest::try_from_platform_versioned(self, platform_version) } + + /// Decode a wire-format [`GetDocumentsRequest`] back into a rich + /// [`DocumentQuery`] — the inverse of + /// [`Self::try_into_request_for_version`], and the piece that lets + /// a client recover the query given only the request bytes it + /// sent. + /// + /// Both wire versions are handled, mirroring how the server + /// decodes each: + /// - **V0** carries `where` / `order_by` as CBOR-encoded arrays of + /// clause components; they are decoded exactly as + /// rs-drive-abci's `query_documents_v0` does (ciborium → + /// `Value::Array` → `WhereClause::from_components` / + /// `OrderClause::from_components`). V0 has no `select` / + /// `group_by` / `having` / `offset`; those default to the + /// documents-fetch shape. + /// - **V1** carries typed proto clauses; they are decoded through + /// the same [`proto_conversions`](super::proto_conversions) + /// functions the server's v1 handler runs, so client and server + /// cannot disagree on what the bytes mean. Multi-projection + /// `selects` (len > 1) is rejected — a `DocumentQuery` carries a + /// single projection, matching what the server evaluates. + /// `limit: Some(0)` is rejected, mirroring the server's uniform + /// `InvalidLimit` contract (`None` = server default → `0` + /// sentinel here; only positive caps are representable). + /// + /// The `prove` flag is intentionally ignored: `DocumentQuery` has + /// no prove field (its encoders always set `prove: true`, because + /// the `FromProof` decoders only handle proved responses). + /// + /// `contract` must be the data contract the request targets — the + /// request's `data_contract_id` is checked against `contract.id()` + /// and the named document type must exist on it. + /// + /// Scope caveat: this mirrors the server's *wire-shape* decoding + /// (shared clause decoders), not its full `validate_and_route` + /// business rules — e.g. SUM/AVG requiring a non-empty field, + /// GROUP BY being illegal with SELECT DOCUMENTS, or HAVING being + /// unimplemented are enforced server-side only. A request violating + /// those decodes here but can never yield a provable response from + /// a real server. That gap matters precisely for fabricated + /// request/response pairs, so a proof-verifying entry point built + /// on this decode must reject every such shape before delegating, + /// rather than letting the lowering to [`DriveDocumentQuery`] + /// silently drop it. + pub fn try_from_request( + request: GetDocumentsRequest, + contract: Arc, + ) -> Result { + match request.version { + Some(V0(request_v0)) => Self::try_from_request_v0(request_v0, contract), + Some(V1(request_v1)) => Self::try_from_request_v1(request_v1, contract), + None => Err(Error::Protocol(ProtocolError::DecodingError( + "GetDocumentsRequest has no version set".to_string(), + ))), + } + } + + fn try_from_request_v0( + request: GetDocumentsRequestV0, + contract: Arc, + ) -> Result { + let GetDocumentsRequestV0 { + data_contract_id, + document_type, + r#where, + order_by, + limit, + // See `try_from_request`: DocumentQuery has no prove field. + prove: _, + start, + } = request; + + check_request_targets_contract(&contract, &data_contract_id, &document_type)?; + + let where_clauses = where_clauses_from_cbor(&r#where)?; + let order_by_clauses = order_clauses_from_cbor(&order_by)?; + + Ok(Self { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: document_type, + where_clauses, + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses, + // V0's plain `uint32` uses the same `0` = "unset" sentinel + // as this struct — pass through. + limit, + offset: None, + start, + }) + } + + fn try_from_request_v1( + request: GetDocumentsRequestV1, + contract: Arc, + ) -> Result { + let GetDocumentsRequestV1 { + data_contract_id, + document_type, + where_clauses, + order_by, + limit, + start, + // See `try_from_request`: DocumentQuery has no prove field. + prove: _, + selects, + group_by, + having, + offset, + } = request; + + check_request_targets_contract(&contract, &data_contract_id, &document_type)?; + + let where_clauses = proto_conversions::where_clauses_from_proto(where_clauses)?; + let order_by_clauses = proto_conversions::order_clauses_from_proto(order_by)?; + let having = proto_conversions::having_clauses_from_proto(having)?; + + // Same shape the server's v1 handler accepts: 0 selects → + // default documents projection, 1 select → decode it, more → + // reject (a `DocumentQuery` carries a single projection; + // multi-projection is wire-only today and the server refuses + // it too). + if selects.len() > 1 { + return Err(Error::Protocol(ProtocolError::DecodingError(format!( + "multi-projection SELECT is not supported: a DocumentQuery carries a \ + single projection, got {} selects", + selects.len() + )))); + } + let select = selects + .into_iter() + .next() + .map(proto_conversions::select_from_proto) + .transpose()? + .unwrap_or_else(SelectProjection::documents); + + // Mirror the server's uniform v1 limit contract: `None` = use + // the server default (the `0` sentinel here), positive = + // explicit cap, `Some(0)` invalid (and unrepresentable — this + // struct's `0` means "unset"). + let limit = match limit { + None => 0, + Some(0) => { + return Err(Error::Protocol(ProtocolError::DecodingError( + "limit = 0 is not a valid wire value on the v1 `optional uint32` \ + field; omit `limit` (None) to use the server's default, or pass \ + a positive integer for an explicit cap" + .to_string(), + ))); + } + Some(n) => n, + }; + + // V1 ships its own `Start` enum with the same shape as V0's; + // this struct stores the V0 type (see `encode_v1` for the + // inverse translation). + let start = start.map(|s| match s { + V1Start::StartAfter(b) => Start::StartAfter(b), + V1Start::StartAt(b) => Start::StartAt(b), + }); + + Ok(Self { + select, + data_contract: contract, + document_type_name: document_type, + where_clauses, + group_by, + having, + order_by_clauses, + limit, + offset, + start, + }) + } +} + +/// Shared request-vs-contract consistency check for both wire +/// versions: the request must target the supplied contract, and the +/// named document type must exist on it. +fn check_request_targets_contract( + contract: &DataContract, + data_contract_id: &[u8], + document_type_name: &str, +) -> Result<(), Error> { + if data_contract_id != contract.id().as_slice() { + return Err(Error::Protocol(ProtocolError::DecodingError(format!( + "GetDocumentsRequest targets data contract {} but the supplied contract is {}", + hex::encode(data_contract_id), + contract.id() + )))); + } + contract + .document_type_for_name(document_type_name) + .map_err(ProtocolError::DataContractError)?; + Ok(()) +} + +/// Decode a V0 `where` field — CBOR bytes carrying an array of +/// `[field, operator, value]` component arrays — into structured +/// clauses. Byte-for-byte mirror of the decode the server's +/// `query_documents_v0` runs (empty bytes → no clauses; anything +/// else must be a CBOR array of arrays). +fn where_clauses_from_cbor(bytes: &[u8]) -> Result, Error> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let value: Value = ciborium::de::from_reader(bytes).map_err(|_| { + Error::Protocol(ProtocolError::DecodingError( + "unable to decode 'where' query from cbor".to_string(), + )) + })?; + match value { + Value::Null => Ok(Vec::new()), + Value::Array(clauses) => clauses + .iter() + .map(|wc| match wc { + Value::Array(components) => { + WhereClause::from_components(components).map_err(Error::Drive) + } + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "where clause must be an array".to_string(), + ))), + }) + .collect(), + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "where clause must be an array".to_string(), + ))), + } +} + +/// Decode a V0 `order_by` field — CBOR bytes carrying an array of +/// `[field, "asc"|"desc"]` component arrays — into structured +/// clauses. Mirror of the server-side decode, like +/// [`where_clauses_from_cbor`]. +fn order_clauses_from_cbor(bytes: &[u8]) -> Result, Error> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let value: Value = ciborium::de::from_reader(bytes).map_err(|_| { + Error::Protocol(ProtocolError::DecodingError( + "unable to decode 'order_by' query from cbor".to_string(), + )) + })?; + match value { + Value::Null => Ok(Vec::new()), + Value::Array(clauses) => clauses + .iter() + .map(|oc| match oc { + Value::Array(components) => { + OrderClause::from_components(components).map_err(|_| { + Error::Protocol(ProtocolError::DecodingError( + "invalid order_by clause components".to_string(), + )) + }) + } + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "order_by clause must be an array".to_string(), + ))), + }) + .collect(), + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "order_by must be an array".to_string(), + ))), + } } impl FromProof for Document { diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index 6c3d07d6b1..eb9d793817 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -12,9 +12,10 @@ pub mod document_split_sums; pub mod document_sum; pub(crate) mod having_proof_helpers; /// Client-side wire-proto → drive-type decoders for `getDocuments`, -/// mirroring rs-drive-abci's server request decode; the two must be -/// kept in lockstep (see the module docs). -#[allow(dead_code)] // consumer (`DocumentQuery::try_from_request`) lands next +/// consumed by +/// [`document_query::DocumentQuery::try_from_request`]. They mirror +/// rs-drive-abci's server request decode and must be kept in +/// lockstep with it (see the module docs). pub(crate) mod proto_conversions; pub(crate) mod ranked_proof_helpers; pub(crate) mod sum_proof_helpers; diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs index ee42f67b7e..f30573c3a7 100644 --- a/packages/dash-platform-queries/src/error.rs +++ b/packages/dash-platform-queries/src/error.rs @@ -29,6 +29,22 @@ pub enum Error { Protocol(#[from] ProtocolError), } +impl From for Error { + fn from(value: crate::documents::proto_conversions::DecodeError) -> Self { + use crate::documents::proto_conversions::DecodeError; + match value { + // Malformed wire bytes — a decoding failure, not a + // misconfiguration. + DecodeError::InvalidArgument(msg) => Self::Protocol(ProtocolError::DecodingError(msg)), + // Well-formed wire shape the decode target can't express + // yet — same classification the server gives it. + DecodeError::Unsupported(msg) => Self::Drive(drive::error::Error::Query( + drive::error::query::QuerySyntaxError::Unsupported(msg), + )), + } + } +} + impl From for Error { fn from(value: ConsensusError) -> Self { Self::Protocol(ProtocolError::ConsensusError(Box::new(value))) From 29c4233bd983ea7d1ad4e9beae05a45ed5c94106 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:30:47 -0500 Subject: [PATCH 6/6] feat(sdk): request-bound document proof verification shared by SDK and embedders verify_documents_response verifies a proved GetDocumentsResponse directly against the wire request that produced it: the wire version (V0/V1 oneof arm) is checked against the platform version's document_query feature bounds (the server's own dispatch gate), prove=false requests are rejected (an honest server answers them unproved), and the request decodes through the shared try_from_request before delegating to FromProof. The query-shape gates (HAVING, GROUP BY, OFFSET, non-documents SELECT - every field the DocumentQuery -> DriveDocumentQuery lowering drops) run inside the shared FromProof impl itself rather than only at the wire entry point. dash-sdk's document fetches verify through that impl, and the SDK talks to the same untrusted evonodes an embedder's transport does, so both paths now reject request shapes no honest server would have proved before any proof machinery runs. --- packages/dash-platform-queries/README.md | 21 +- .../src/documents/document_query.rs | 262 +++++++++++++++++- 2 files changed, 267 insertions(+), 16 deletions(-) diff --git a/packages/dash-platform-queries/README.md b/packages/dash-platform-queries/README.md index 1587d9a58d..b4b71d1bab 100644 --- a/packages/dash-platform-queries/README.md +++ b/packages/dash-platform-queries/README.md @@ -39,17 +39,18 @@ If you want networking, retries, and a managed connection pool, use ## What's here -- `DocumentQuery` — rich document query builder with wire - encoding for both request versions. +- `DocumentQuery` — rich document query builder, wire encoding for both + request versions, and decoding **from** the wire request + (`DocumentQuery::try_from_request`) via decoders that mirror the server's + (`drive-abci`'s `v1/conversions.rs`) and are kept in lockstep with them. +- `verify_documents_response` — request-driven proof verification for document + queries, delegating to `drive-proof-verifier`'s `FromProof`. - Aggregate proof helpers (count/sum/average/ranked) shared with `dash-sdk`. -- DPNS username helpers — label normalization/validation and the - convertibility/contested checks shared with `dash-sdk`. -- `transition::validation` — structural validation for state transitions - ahead of signing. - -Wire-request decoding (`DocumentQuery::try_from_request`), request-driven -proof verification, and pure DPNS/DashPay document builders arrive in the -next slice of this series. +- Pure DPNS builders — `build_dpns_preorder_and_domain_documents`, label + normalization/validation — and pure DashPay contact-request document + assembly (`dashpay::build_contact_request_document`); crypto material is + supplied by the caller, keys never enter this crate. +- `transition::validation` helpers. ## Feature flags diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 856fa4fa1e..5b9699a8dc 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -388,8 +388,8 @@ impl DocumentQuery { /// Decode a wire-format [`GetDocumentsRequest`] back into a rich /// [`DocumentQuery`] — the inverse of /// [`Self::try_into_request_for_version`], and the piece that lets - /// a client recover the query given only the request bytes it - /// sent. + /// an embedder verify a proved response given only the request + /// bytes it sent (see [`verify_documents_response`]). /// /// Both wire versions are handled, mirroring how the server /// decodes each: @@ -425,10 +425,10 @@ impl DocumentQuery { /// unimplemented are enforced server-side only. A request violating /// those decodes here but can never yield a provable response from /// a real server. That gap matters precisely for fabricated - /// request/response pairs, so a proof-verifying entry point built - /// on this decode must reject every such shape before delegating, - /// rather than letting the lowering to [`DriveDocumentQuery`] - /// silently drop it. + /// request/response pairs, so the proof-verifying entry point + /// [`verify_documents_response`] closes it: it rejects every such + /// shape before delegating, rather than letting the lowering to + /// [`DriveDocumentQuery`] silently drop it. pub fn try_from_request( request: GetDocumentsRequest, contract: Arc, @@ -652,6 +652,250 @@ fn order_clauses_from_cbor(bytes: &[u8]) -> Result, Error> { } } +/// Reject a request whose wire version (the `V0`/`V1` oneof arm, +/// i.e. feature version 0/1) falls outside the supplied platform +/// version's `drive_abci.query.document_query` bounds. +/// +/// This is the same `check_version` gate the server runs before it +/// decodes anything (`Platform::query_documents` in rs-drive-abci, +/// which answers an out-of-bounds wire version with +/// `QueryError::UnsupportedQueryVersion`). Without it, an untrusted +/// transport could pair a request wire-version the supplied platform +/// version's server refuses to serve with a valid proof produced for +/// the other wire shape, and verification would accept the pair. +/// +/// A missing `version` oneof is deliberately let through — the decode +/// that follows reports it with its established error message. +fn check_wire_version_is_served( + request: &GetDocumentsRequest, + platform_version: &PlatformVersion, +) -> Result<(), drive_proof_verifier::Error> { + let Some(version) = &request.version else { + return Ok(()); + }; + let feature_version: u16 = match version { + V0(_) => 0, + V1(_) => 1, + }; + let bounds = &platform_version.drive_abci.query.document_query; + if !bounds.check_version(feature_version) { + return Err(drive_proof_verifier::Error::RequestError { + error: format!( + "GetDocumentsRequest wire version V{feature_version} is outside the \ + document_query feature-version bounds {}..={} served at platform version \ + {}; the server answers such a request with UnsupportedQueryVersion, so no \ + proved response can belong to it", + bounds.min_version, bounds.max_version, platform_version.protocol_version + ), + }); + } + Ok(()) +} + +/// Reject the request shapes that can never have produced the proved +/// plain-document response being verified. +/// +/// Each rejection mirrors a gate an honest server runs before it would +/// ever build such a proof, and each covers a field the +/// `DocumentQuery` → [`DriveDocumentQuery`] lowering discards — which +/// is exactly the set an attacker could vary freely while replaying a +/// genuine proof. See [`verify_documents_response`] for the threat +/// model. +/// +/// This runs inside the [`FromProof`] impl itself — the +/// shared choke point — so every `DocumentQuery`-keyed verification +/// gets it: `dash-sdk`'s own document fetches talk to the same +/// untrusted nodes an embedder's transport does, and are protected by +/// the same gates as the wire-request entry point +/// [`verify_documents_response`]. +/// +/// Server counterparts, all in +/// `packages/rs-drive-abci/src/query/document_query/v1/mod.rs`: +/// `validate_and_route` rejects a non-empty HAVING for any +/// non-aggregate SELECT and a non-empty GROUP BY under SELECT +/// DOCUMENTS; `reject_offset_off_the_ranked_path` rejects any OFFSET +/// that did not route to the ranked executor (a documents fetch never +/// does). +fn reject_request_the_server_would_not_have_proved( + query: &DocumentQuery, +) -> Result<(), drive_proof_verifier::Error> { + let reject = |error: String| Err(drive_proof_verifier::Error::RequestError { error }); + + // This path verifies plain document fetches only. An aggregate + // projection (COUNT/SUM/AVG) is proved with a different proof shape; + // handing it to the Documents verifier would surface as an opaque + // low-level proof error, so reject it up front instead. + if query.select != drive::query::SelectProjection::documents() { + return reject(format!( + "only a plain SELECT DOCUMENTS fetch can be verified here; the request carries \ + the projection {:?} — aggregate projections are verified by the aggregate \ + proof helpers", + query.select + )); + } + if !query.having.is_empty() { + return reject(format!( + "request carries {} HAVING clause(s), which the server refuses for a \ + non-aggregate SELECT; no proved document response can belong to it", + query.having.len() + )); + } + if !query.group_by.is_empty() { + return reject(format!( + "request carries GROUP BY {:?}, which the server refuses under SELECT DOCUMENTS; \ + no proved document response can belong to it", + query.group_by + )); + } + if let Some(offset) = query.offset { + return reject(format!( + "request carries OFFSET {offset}, which the server accepts only on the ranked \ + surface, never for a document fetch; no proved document response can belong to it" + )); + } + Ok(()) +} + +/// Embedder entry point: verify a proved [`GetDocumentsResponse`] +/// directly against the wire request that produced it. +/// +/// This is the transport-free glue an embedder needs when it drives +/// its own transport: it holds the `GetDocumentsRequest` it sent and +/// the `GetDocumentsResponse` it got back, and this function does the +/// rest — decodes the request into a [`DocumentQuery`] (via +/// [`DocumentQuery::try_from_request`], whose decoders mirror the +/// server's request decode) and delegates to the existing +/// [`FromProof`] machinery, which resolves the +/// [`DriveDocumentQuery`] internally and cryptographically verifies +/// the proof against it. +/// +/// `contract` must be the data contract the request targets. If the +/// embedder's [`ContextProvider`] can resolve contracts, use +/// [`verify_documents_response_with_provider_contract`] instead and +/// skip the explicit parameter. +/// +/// # Binding the proof to the whole request +/// +/// GroveDB and Tenderdash proofs authenticate the state and the +/// resolved [`DriveDocumentQuery`] — not the request envelope. The +/// rich→drive lowering drops request fields that a documents query has +/// no place for (`group_by`, `having`, `offset`, `prove`), so +/// delegating without first checking them would let an untrusted +/// transport pair a request the real server would have *refused* with +/// a valid proof for the narrower query it lowers to, and this +/// function would accept it. Every such field is therefore rejected +/// before any proof machinery runs — `prove` here on the wire request, +/// and the query-shape fields inside the shared +/// [`FromProof`] impl (so `dash-sdk`'s own fetches run +/// the identical gates) — mirroring the server's own gates in +/// `rs-drive-abci`'s `validate_and_route` / +/// `reject_offset_off_the_ranked_path`. +/// +/// The same reasoning covers the request envelope itself: the wire +/// version (`V0`/`V1` oneof arm) is checked against +/// `platform_version.drive_abci.query.document_query`'s bounds before +/// anything is decoded — the server's `query_documents` dispatch +/// refuses an out-of-bounds wire version with +/// `UnsupportedQueryVersion`, so a proof can never belong to one — and +/// the query limit contract is enforced during the +/// `DriveDocumentQuery` lowering exactly as +/// `DriveDocumentQuery::from_typed_clauses` enforces it server-side: +/// an omitted limit resolves to the server default and a limit above +/// [`DEFAULT_QUERY_LIMIT`] is rejected. +pub fn verify_documents_response( + request: GetDocumentsRequest, + contract: Arc, + response: platform_proto::GetDocumentsResponse, + network: Network, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> { + // First gate, mirroring the server's own dispatch order: a wire + // version the supplied platform version's server refuses to serve + // is rejected before any decoding or proof machinery. + check_wire_version_is_served(&request, platform_version)?; + // `prove` does not survive decoding (a `DocumentQuery` has no such + // field), so read it off the wire request before it is consumed. + // `prove: false` is not a server rejection — it makes the server + // return an unproved response, so a proved response cannot have + // come from one. + let prove = match &request.version { + Some(V0(v0)) => v0.prove, + Some(V1(v1)) => v1.prove, + // Missing version is reported by the decode below. + None => true, + }; + if !prove { + return Err(drive_proof_verifier::Error::RequestError { + error: "request carries prove=false, so an honest server would have answered it \ + with an unproved response; a proved response cannot belong to this request" + .to_string(), + }); + } + let query = DocumentQuery::try_from_request(request, contract).map_err(|e| { + drive_proof_verifier::Error::RequestError { + error: format!("failed to decode GetDocumentsRequest into a DocumentQuery: {e}"), + } + })?; + // The remaining request-shape gates + // (`reject_request_the_server_would_not_have_proved`) run inside the + // shared `FromProof` impl this delegates to. + >::maybe_from_proof_with_metadata( + query, + response, + network, + platform_version, + provider, + ) +} + +/// Variant of [`verify_documents_response`] that resolves the data +/// contract through the [`ContextProvider`] +/// ([`ContextProvider::get_data_contract`]) instead of taking it as a +/// parameter — for embedders whose provider already caches or fetches +/// contracts. +pub fn verify_documents_response_with_provider_contract( + request: GetDocumentsRequest, + response: platform_proto::GetDocumentsResponse, + network: Network, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> { + // Same first gate as `verify_documents_response`, run here as well + // so an out-of-bounds wire version is rejected before the provider + // is asked for anything (the contract lookup below is already + // context-provider machinery). + check_wire_version_is_served(&request, platform_version)?; + let contract_id_bytes = match &request.version { + Some(V0(v0)) => v0.data_contract_id.as_slice(), + Some(V1(v1)) => v1.data_contract_id.as_slice(), + None => { + return Err(drive_proof_verifier::Error::RequestError { + error: "GetDocumentsRequest has no version set".to_string(), + }); + } + }; + let contract_id = Identifier::from_bytes(contract_id_bytes).map_err(|e| { + drive_proof_verifier::Error::RequestError { + error: format!("invalid data_contract_id in GetDocumentsRequest: {e}"), + } + })?; + let contract = provider + .get_data_contract(&contract_id, platform_version) + .map_err(drive_proof_verifier::Error::ContextProviderError)? + .ok_or_else(|| drive_proof_verifier::Error::RequestError { + error: format!("context provider has no data contract {contract_id}"), + })?; + verify_documents_response( + request, + contract, + response, + network, + platform_version, + provider, + ) +} + impl FromProof for Document { type Request = DocumentQuery; type Response = platform_proto::GetDocumentsResponse; @@ -706,6 +950,12 @@ impl FromProof for drive_proof_verifier::types::Documents { Self: Sized + 'a, { let request: Self::Request = request.into(); + // Server-parity request gates run here, at the shared choke + // point, so every `DocumentQuery`-keyed verification — dash-sdk + // fetches and the wire-request entry points alike — rejects + // request shapes no honest server would have proved before any + // proof machinery runs. + reject_request_the_server_would_not_have_proved(&request)?; let drive_query: DriveDocumentQuery = (&request) .try_into()