From c4111678ee969c848a9d7b026e46259e34973054 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 18:14:08 -0500 Subject: [PATCH 1/2] feat(drive-proof-verifier): verify document proofs against the wire request Adds FromProof for a new RequestedDocuments newtype, plus the DocumentWireQuery it decodes through (TryFromRequest, like every other request-driven verifier in this crate). Embedders that own their transport (the Dash Core platform GUI, explorers) hold their request only as protobuf bytes, so the verifier must rebuild the query the same way the server did: v1 clauses decode through the shared platform-query-wire decoder rs-drive-abci uses, v0 CBOR clauses through drive's from_components parsers, and both lower through DriveDocumentQuery::from_typed_clauses under the default DriveConfig and the response's platform version. Request shapes the server never answers with a plain proved document set (aggregate SELECT, GROUP BY, HAVING, OFFSET, chained, prove=false, an unserved wire version) are refused up front; the data contract is resolved through the ContextProvider so a server cannot supply the schema. RequestedDocuments is a newtype because Documents already carries a blanket FromProof> that coherence will not let a concrete GetDocumentsRequest impl sit beside. Tests replay the existing proof-vector corpus from wire requests (v1 for all four document fixtures, v0 CBOR for one) and pin every shape gate. --- Cargo.lock | 2 + packages/rs-drive-proof-verifier/Cargo.toml | 2 + packages/rs-drive-proof-verifier/src/lib.rs | 1 + packages/rs-drive-proof-verifier/src/proof.rs | 1 + .../src/proof/document_request.rs | 480 ++++++++++++++++++ .../tests/vectors_document_request.rs | 472 +++++++++++++++++ 6 files changed, 958 insertions(+) create mode 100644 packages/rs-drive-proof-verifier/src/proof/document_request.rs create mode 100644 packages/rs-drive-proof-verifier/tests/vectors_document_request.rs diff --git a/Cargo.lock b/Cargo.lock index 908681a6868..99bf79e01d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,6 +2285,7 @@ name = "drive-proof-verifier" version = "4.2.0-dev.8" dependencies = [ "bincode", + "ciborium", "dapi-grpc", "dash-context-provider", "derive_more 1.0.0", @@ -2292,6 +2293,7 @@ dependencies = [ "drive", "hex", "indexmap 2.14.0", + "platform-query-wire", "platform-serialization", "platform-serialization-derive", "serde", diff --git a/packages/rs-drive-proof-verifier/Cargo.toml b/packages/rs-drive-proof-verifier/Cargo.toml index d0246c9a8eb..b211dab2d88 100644 --- a/packages/rs-drive-proof-verifier/Cargo.toml +++ b/packages/rs-drive-proof-verifier/Cargo.toml @@ -32,6 +32,8 @@ dash-context-provider = { path = "../rs-context-provider", features = [ "mocks", ] } bincode = { version = "=2.0.1", features = ["serde"] } +ciborium = { version = "0.2.2" } +platform-query-wire = { path = "../rs-platform-query-wire" } platform-serialization-derive = { path = "../rs-platform-serialization-derive", optional = true } platform-serialization = { path = "../rs-platform-serialization" } tenderdash-abci = { git = "https://github.com/dashpay/rs-tenderdash-abci", tag = "v1.5.1", features = [ diff --git a/packages/rs-drive-proof-verifier/src/lib.rs b/packages/rs-drive-proof-verifier/src/lib.rs index 55c201b1cf9..a2a20a31a52 100644 --- a/packages/rs-drive-proof-verifier/src/lib.rs +++ b/packages/rs-drive-proof-verifier/src/lib.rs @@ -33,6 +33,7 @@ pub use proof::document_having::{verify_having_range_proof, DocumentHavingEntrie /// that binds the proof's reconstructed root hash to the signed app /// hash and returns the whole verified [`drive::query::RankedPage`]. pub use proof::document_ranked::{verify_ranked_top_k_proof, DocumentRankedEntries}; +pub use proof::document_request::{DocumentWireQuery, RequestedDocuments}; pub use proof::document_split_count::DocumentSplitCounts; // Re-export `SplitCountEntry` from rs-drive at the proof-verifier // crate root so SDK consumers don't have to depend on rs-drive diff --git a/packages/rs-drive-proof-verifier/src/proof.rs b/packages/rs-drive-proof-verifier/src/proof.rs index 39a7dd34497..69c3e4268e4 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -18,6 +18,7 @@ pub mod document_having; /// plus the attested rank the page starts at, read from an indexed /// tree's per-axis secondary (grovedb PR 657); see the file's docs. pub mod document_ranked; +pub mod document_request; /// Per-entry verified average result. One `(in_key, key, count, sum)` /// tuple per matched group; client divides per-entry to obtain /// per-group averages. diff --git a/packages/rs-drive-proof-verifier/src/proof/document_request.rs b/packages/rs-drive-proof-verifier/src/proof/document_request.rs new file mode 100644 index 00000000000..80721ec5105 --- /dev/null +++ b/packages/rs-drive-proof-verifier/src/proof/document_request.rs @@ -0,0 +1,480 @@ +//! Request-driven document proof verification. +//! +//! [`FromProof`] reconstructs the [`DriveDocumentQuery`] +//! an honest server ran from the *wire request bytes that were actually sent*, +//! then verifies the proved response against it. This is the entry point for +//! embedders that own their transport (the Dash Core platform GUI, explorers): +//! their request only ever exists as protobuf bytes, so the verifier must +//! rebuild the query the same way the server did. +//! +//! The reconstruction runs the server's own pipeline: +//! - the wire decode is the shared `platform_query_wire` decoder rs-drive-abci +//! decodes incoming requests with, so server and verifier cannot drift on +//! clause interpretation; +//! - the lowering into a [`DriveDocumentQuery`] is rs-drive's +//! [`DriveDocumentQuery::from_typed_clauses`] under the default +//! [`DriveConfig`] (the limit contract every deployed server runs with) and +//! the platform version the response was produced under; +//! - request shapes the server would never have answered with a plain proved +//! document set (aggregate projections, `HAVING`, `GROUP BY`, `OFFSET`, +//! `prove = false`, a wire version outside the served bounds) are rejected +//! up front, since a proof can never belong to them. + +use crate::from_request::TryFromRequest; +use crate::types::Documents; +use crate::{ContextProvider, Error, FromProof, Length}; +use dapi_grpc::platform::v0::get_documents_request::{ + get_documents_request_v0::Start as V0Start, get_documents_request_v1::Start as V1Start, + GetDocumentsRequestV0, GetDocumentsRequestV1, Version, +}; +use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, Proof, ResponseMetadata}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::version::PlatformVersion; +use drive::config::DriveConfig; +use drive::query::{ + resolve_time_range_bucket_clause, DriveDocumentQuery, OrderClause, ResolvedTimeRange, + SelectProjection, TimeRangeGridSpec, TimeRangeSelector, WhereClause, +}; +use platform_query_wire::proto_conversions as wire; +use std::sync::Arc; + +/// The [`GetDocumentsRequest`] fields a proved plain-document response is +/// verified against, decoded off the wire but not yet bound to a contract. +/// +/// Built by [`TryFromRequest`] from either wire version. The data contract is +/// resolved separately (through the [`ContextProvider`]) because a +/// [`DriveDocumentQuery`] borrows it. +#[derive(Debug, Clone, PartialEq)] +pub struct DocumentWireQuery { + /// Contract the request targets. + pub data_contract_id: Identifier, + /// Document type name on that contract. + pub document_type_name: String, + /// Decoded `WHERE` clauses, with any `IN_TIME_RANGE` selections still + /// pending in `time_ranges`. + pub where_clauses: Vec, + /// `IN_TIME_RANGE` selections (v1 only), resolved against the + /// quorum-signed block time once the response is known. + pub time_ranges: Vec, + /// Decoded `ORDER BY` clauses. + pub order_by: Vec, + /// Wire limit; `None` means "server default". A v0 request's `0` is + /// normalised to `None` here, since on that wire `0` is the only way to + /// leave the limit unset; a v1 `Some(0)` is refused at decode. + pub limit: Option, + /// Pagination cursor and whether it is inclusive. + pub start_at: Option<[u8; 32]>, + /// `true` for `start_at`, `false` for `start_after`; the server default + /// when no cursor is given. + pub start_at_included: bool, +} + +/// A pending `IN_TIME_RANGE` selection: `(field, selector, grid)`. +pub type TimeRangeSelection = (String, TimeRangeSelector, Option); + +impl TryFromRequest for DocumentWireQuery { + fn try_from_request(grpc_request: GetDocumentsRequest) -> Result { + match grpc_request.version.ok_or(Error::EmptyVersion)? { + Version::V0(v0) => Self::try_from_v0(v0), + Version::V1(v1) => Self::try_from_v1(v1), + } + } + + /// The wire encoder for document queries lives with the rich + /// `DocumentQuery` builder in `dash-platform-queries` + /// (`GetDocumentsRequest::try_from_platform_versioned`); this type only + /// runs the decode direction, and refuses rather than duplicating it. + fn try_to_request(&self) -> Result { + Err(request_error( + "DocumentWireQuery is decode-only; encode a dash_platform_queries DocumentQuery", + )) + } +} + +fn request_error(error: impl std::fmt::Display) -> Error { + Error::RequestError { + error: error.to_string(), + } +} + +fn cursor(bytes: Vec, what: &str) -> Result<[u8; 32], Error> { + bytes + .try_into() + .map_err(|_| request_error(format!("{what} should be a 32 byte identifier"))) +} + +impl DocumentWireQuery { + fn try_from_v0(request: GetDocumentsRequestV0) -> Result { + let GetDocumentsRequestV0 { + data_contract_id, + document_type, + r#where, + order_by, + limit, + prove, + start, + } = request; + reject_unproved(prove)?; + + // The v0 wire carries CBOR arrays of `[field, operator, value]` / + // `[field, "asc"|"desc"]`; decode them through the same + // `from_components` parsers the server's v0 handler uses. + let where_clauses = match cbor_array(&r#where, "where")? { + None => Vec::new(), + Some(clauses) => clauses + .iter() + .map(|clause| match clause { + Value::Array(components) => { + WhereClause::from_components(components).map_err(request_error) + } + _ => Err(request_error("where clause must be an array")), + }) + .collect::>()?, + }; + let order_by = match cbor_array(&order_by, "order_by")? { + None => Vec::new(), + Some(clauses) => clauses + .iter() + .map(|clause| match clause { + Value::Array(components) => OrderClause::from_components(components) + .map_err(|_| request_error("invalid order_by clause components")), + _ => Err(request_error("order_by clause must be an array")), + }) + .collect::>()?, + }; + let (start_at, start_at_included) = match start { + None => (None, true), + Some(V0Start::StartAt(at)) => (Some(cursor(at, "start at")?), true), + Some(V0Start::StartAfter(after)) => (Some(cursor(after, "start after")?), false), + }; + Ok(Self { + data_contract_id: Identifier::from_bytes(&data_contract_id).map_err(request_error)?, + document_type_name: document_type, + where_clauses, + time_ranges: Vec::new(), + order_by, + limit: (limit != 0).then_some(limit), + start_at, + start_at_included, + }) + } + + fn try_from_v1(request: GetDocumentsRequestV1) -> Result { + // Destructured non-exhaustively: the generated request gains fields + // as the query surface grows (e.g. `sub_queries` behind a feature), + // and any field this verifier does not understand is checked below + // through `Default` rather than silently ignored. + let GetDocumentsRequestV1 { + data_contract_id, + document_type, + where_clauses, + order_by, + limit, + start, + prove, + selects, + group_by, + having, + offset, + chained, + .. + } = request.clone(); + reject_unproved(prove)?; + let recognised = GetDocumentsRequestV1 { + data_contract_id: request.data_contract_id.clone(), + document_type: request.document_type.clone(), + where_clauses: request.where_clauses.clone(), + order_by: request.order_by.clone(), + limit: request.limit, + start: request.start.clone(), + prove: request.prove, + selects: request.selects.clone(), + group_by: request.group_by.clone(), + having: request.having.clone(), + offset: request.offset, + chained: request.chained.clone(), + ..Default::default() + }; + if recognised != request { + return Err(request_error( + "request carries fields this verifier does not understand (e.g. sub-queries); \ + no proved plain-document response can be verified against it", + )); + } + + // Shapes the server routes anywhere but the plain document fetch. + // Each mirrors a gate in rs-drive-abci's `query_documents_v1`; a + // proved plain-document response can never belong to a request that + // trips one, so refuse before touching proof machinery. + if chained.is_some() { + return Err(request_error( + "chained document requests are verified through ChainedDocumentQuery", + )); + } + if selects.len() > 1 { + return Err(request_error( + "multi-projection SELECT is not served; no proved response can belong to it", + )); + } + if let Some(select) = selects.into_iter().next() { + let select = wire::select_from_proto(select).map_err(request_error)?; + if select != SelectProjection::documents() { + return Err(request_error(format!( + "only SELECT DOCUMENTS is verified here; {select:?} is an aggregate \ + projection with its own proof shape" + ))); + } + } + if !group_by.is_empty() { + return Err(request_error( + "GROUP BY is refused by the server under SELECT DOCUMENTS", + )); + } + if !having.is_empty() { + return Err(request_error( + "HAVING is refused by the server for a non-aggregate SELECT", + )); + } + if let Some(offset) = offset { + return Err(request_error(format!( + "OFFSET {offset} is only served on the ranked surface, never for a document fetch" + ))); + } + + let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = where_clauses + .into_iter() + .partition(wire::is_time_range_clause); + let time_ranges = time_range_proto + .into_iter() + .map(|clause| wire::time_range_clause_from_proto(clause).map_err(request_error)) + .collect::>()?; + let where_clauses = wire::where_clauses_from_proto(normal_proto).map_err(request_error)?; + let order_by = wire::order_clauses_from_proto(order_by).map_err(request_error)?; + let (start_at, start_at_included) = match start { + None => (None, true), + Some(V1Start::StartAt(at)) => (Some(cursor(at, "start at")?), true), + Some(V1Start::StartAfter(after)) => (Some(cursor(after, "start after")?), false), + }; + // The v1 wire has `optional uint32 limit`, so "use the default" is + // spelled `None`; the server refuses an explicit `Some(0)` outright + // (`validate_and_route`), unlike v0 where `0` is the only way to + // leave the limit unset. + if limit == Some(0) { + return Err(request_error( + "limit = 0 is not a valid v1 wire value; omit the limit for the server default", + )); + } + Ok(Self { + data_contract_id: Identifier::from_bytes(&data_contract_id).map_err(request_error)?, + document_type_name: document_type, + where_clauses, + time_ranges, + order_by, + limit, + start_at, + start_at_included, + }) + } + + /// Lower into the [`DriveDocumentQuery`] the server ran, exactly as + /// rs-drive-abci's `query_documents_typed` does: the same + /// `from_typed_clauses` constructor, the default [`DriveConfig`] limit + /// contract, and the platform version the response was produced under. + /// + /// `block_time_ms` is the quorum-signed response time; `IN_TIME_RANGE` + /// selections resolve against it to the same bucket the server used. + pub fn to_drive_query<'a>( + &self, + contract: &'a DataContract, + block_time_ms: Option, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if contract.id() != self.data_contract_id { + return Err(request_error(format!( + "request targets data contract {} but the supplied contract is {}", + self.data_contract_id, + contract.id() + ))); + } + let document_type = contract + .document_type_for_name(&self.document_type_name) + .map_err(|e| request_error(format!("document type: {e}")))?; + + let mut where_clauses = self.where_clauses.clone(); + let mut resolved_time_ranges: Vec = Vec::new(); + if !self.time_ranges.is_empty() { + let block_time_ms = block_time_ms.ok_or_else(|| { + request_error("time range query needs the response block time to resolve") + })?; + for (field, selector, grid) in &self.time_ranges { + let (clause, resolved) = resolve_time_range_bucket_clause( + field, + *selector, + *grid, + document_type, + block_time_ms, + )?; + where_clauses.push(clause); + resolved_time_ranges.push(resolved); + } + } + + // Same translation as the server: `None` falls back to the config + // default inside `from_typed_clauses`; values above `u16::MAX` are + // refused before the cast. + let limit = match self.limit { + Some(n) if n > u32::from(u16::MAX) => { + return Err(request_error(format!("limit {n} out of bounds"))); + } + None => None, + Some(n) => Some(n as u16), + }; + + let mut query = DriveDocumentQuery::from_typed_clauses( + where_clauses, + self.order_by.clone(), + limit, + self.start_at, + self.start_at_included, + None, + contract, + document_type, + &DriveConfig::default(), + platform_version, + )?; + query.resolved_time_ranges = resolved_time_ranges; + Ok(query) + } +} + +fn reject_unproved(prove: bool) -> Result<(), Error> { + if prove { + Ok(()) + } else { + Err(request_error( + "request carries prove=false, so an honest server answered it unproved; a proved \ + response cannot belong to it", + )) + } +} + +/// Decode a v0 CBOR clause field into its top-level array, `None` when the +/// field is empty or CBOR null (both mean "no clauses" to the server). +fn cbor_array(bytes: &[u8], field: &str) -> Result>, Error> { + if bytes.is_empty() { + return Ok(None); + } + let value: Value = ciborium::de::from_reader(bytes) + .map_err(|_| request_error(format!("unable to decode '{field}' query from cbor")))?; + match value { + Value::Null => Ok(None), + Value::Array(clauses) => Ok(Some(clauses)), + _ => Err(request_error(format!("{field} must be an array"))), + } +} + +/// Reject a request whose wire version (`V0`/`V1`) is outside the +/// `document_query` feature-version bounds the given platform version's +/// server serves. The server answers such a request with +/// `UnsupportedQueryVersion`, so no proved response can belong to it. +fn check_wire_version_is_served( + request: &GetDocumentsRequest, + platform_version: &PlatformVersion, +) -> Result<(), Error> { + let feature_version: u16 = match &request.version { + Some(Version::V0(_)) => 0, + Some(Version::V1(_)) => 1, + None => return Err(Error::EmptyVersion), + }; + let bounds = &platform_version.drive_abci.query.document_query; + if !bounds.check_version(feature_version) { + return Err(request_error(format!( + "GetDocumentsRequest wire version V{feature_version} is outside the document_query \ + bounds {}..={} served at platform version {}", + bounds.min_version, bounds.max_version, platform_version.protocol_version + ))); + } + Ok(()) +} + +/// Documents verified against the wire request that fetched them. +/// +/// A distinct type from [`Documents`] because that one carries a blanket +/// `FromProof` for every `Q: TryInto`, which +/// coherence will not let a `GetDocumentsRequest` impl sit beside. +/// `Deref`s to the underlying [`Documents`] map. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RequestedDocuments(pub Documents); + +impl std::ops::Deref for RequestedDocuments { + type Target = Documents; + fn deref(&self) -> &Documents { + &self.0 + } +} + +impl From for Documents { + fn from(value: RequestedDocuments) -> Self { + value.0 + } +} + +impl Length for RequestedDocuments { + fn count_some(&self) -> usize { + self.0.count_some() + } + fn count(&self) -> usize { + self.0.count() + } +} + +impl FromProof for RequestedDocuments { + type Request = GetDocumentsRequest; + type Response = GetDocumentsResponse; + + fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( + request: I, + response: O, + network: Network, + platform_version: &PlatformVersion, + provider: &'a dyn ContextProvider, + ) -> Result<(Option, ResponseMetadata, Proof), Error> + where + Self: 'a, + { + let request: Self::Request = request.into(); + let response: Self::Response = response.into(); + + check_wire_version_is_served(&request, platform_version)?; + let wire_query = DocumentWireQuery::try_from_request(request)?; + + let contract: Arc = provider + .get_data_contract(&wire_query.data_contract_id, platform_version)? + .ok_or_else(|| { + request_error(format!( + "context provider has no data contract {}", + wire_query.data_contract_id + )) + })?; + + // The block time is read off the response *before* verification only + // to resolve time-range buckets; it is bound by the quorum signature + // checked inside the delegated `FromProof`, so a lie fails there. + let block_time_ms = response.metadata().ok().map(|mtd| mtd.time_ms); + let drive_query = wire_query.to_drive_query(&contract, block_time_ms, platform_version)?; + + let (documents, mtd, proof) = + >::maybe_from_proof_with_metadata( + drive_query, + response, + network, + platform_version, + provider, + )?; + Ok((documents.map(RequestedDocuments), mtd, proof)) + } +} diff --git a/packages/rs-drive-proof-verifier/tests/vectors_document_request.rs b/packages/rs-drive-proof-verifier/tests/vectors_document_request.rs new file mode 100644 index 00000000000..767663932c4 --- /dev/null +++ b/packages/rs-drive-proof-verifier/tests/vectors_document_request.rs @@ -0,0 +1,472 @@ +//! Request-driven document verification (`FromProof`), +//! replayed against the proof-vector corpus. +//! +//! The documents fixtures store placeholder payloads at the document +//! positions, so (as in `vectors_documents.rs`) the positive half pins that +//! the wire request decodes into exactly the `DriveDocumentQuery` the fixture +//! proof was generated for: the grovedb layer verifies to the pinned root +//! hash, and `FromProof` then fails cleanly at document decode. The negative +//! half pins the request-shape gates: shapes an honest server would never +//! have answered with a plain proved document set are refused before any +//! proof machinery runs. + +#![cfg(feature = "mocks")] + +mod common; + +use common::{identifier, load_case, Case, Expected, RequestSpec, NETWORK}; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{ + select, Select as ProtoSelect, +}; +use dapi_grpc::platform::v0::get_documents_request::{ + document_field_value, get_documents_request_v1::Start as V1Start, DocumentFieldValue, + GetDocumentsRequestV0, GetDocumentsRequestV1, OrderClause as ProtoOrderClause, Version, + WhereClause as ProtoWhereClause, WhereOperator as ProtoWhereOperator, +}; +use dapi_grpc::platform::v0::{self as platform, get_documents_response, GetDocumentsRequest}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::platform_value::Value; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use drive_proof_verifier::{DocumentWireQuery, Error, FromProof, RequestedDocuments}; + +fn text(value: &str) -> DocumentFieldValue { + DocumentFieldValue { + variant: Some(document_field_value::Variant::Text(value.to_string())), + } +} + +fn identifier_value(hex: &str) -> DocumentFieldValue { + DocumentFieldValue { + variant: Some(document_field_value::Variant::BytesValue( + identifier(hex).to_vec(), + )), + } +} + +fn equal(field: &str, value: DocumentFieldValue) -> ProtoWhereClause { + ProtoWhereClause { + field: field.to_string(), + operator: ProtoWhereOperator::Equal as i32, + value: Some(value), + time_range: None, + } +} + +fn asc(field: &str) -> ProtoOrderClause { + ProtoOrderClause { + target: Some( + dapi_grpc::platform::v0::get_documents_request::order_clause::Target::Field( + field.to_string(), + ), + ), + ascending: true, + } +} + +/// The v1 wire request the Dash Core transport sends for each fixture case +/// (same shapes `vectors_documents.rs` pins as hand-built drive queries). +fn v1_request(case: &Case, contract_id: Vec) -> GetDocumentsRequest { + let (document_type, where_clauses, order_by, limit) = match &case.manifest.request { + RequestSpec::DocumentsDpnsExact { + normalized_label, + limit, + } => ( + "domain", + vec![ + equal("normalizedParentDomainName", text("dash")), + equal("normalizedLabel", text(normalized_label)), + ], + vec![], + *limit, + ), + RequestSpec::DocumentsDpnsPrefix { + normalized_prefix, + limit, + } => ( + "domain", + vec![ + equal("normalizedParentDomainName", text("dash")), + ProtoWhereClause { + field: "normalizedLabel".to_string(), + operator: ProtoWhereOperator::StartsWith as i32, + value: Some(text(normalized_prefix)), + time_range: None, + }, + ], + vec![asc("normalizedLabel")], + *limit, + ), + RequestSpec::DocumentsDashpayProfile { owner_id } => ( + "profile", + vec![equal("$ownerId", identifier_value(owner_id))], + vec![], + 1, + ), + RequestSpec::DocumentsDashpayContacts { + identity_id, + to_identity, + limit, + } => ( + "contactRequest", + vec![equal( + if *to_identity { "toUserId" } else { "$ownerId" }, + identifier_value(identity_id), + )], + vec![asc("$createdAt")], + *limit, + ), + _ => panic!("{}: not a documents request", case.name), + }; + GetDocumentsRequest { + version: Some(Version::V1(GetDocumentsRequestV1 { + data_contract_id: contract_id, + document_type: document_type.to_string(), + where_clauses, + order_by, + limit: Some(u32::from(limit)), + start: None, + prove: true, + selects: vec![], + group_by: vec![], + having: vec![], + offset: None, + chained: None, + ..Default::default() + })), + } +} + +fn contract_for(case: &Case) -> SystemDataContract { + match &case.manifest.request { + RequestSpec::DocumentsDpnsExact { .. } | RequestSpec::DocumentsDpnsPrefix { .. } => { + SystemDataContract::DPNS + } + RequestSpec::DocumentsDashpayProfile { .. } + | RequestSpec::DocumentsDashpayContacts { .. } => SystemDataContract::Dashpay, + _ => panic!("{}: not a documents request", case.name), + } +} + +fn response(case: &Case) -> platform::GetDocumentsResponse { + platform::GetDocumentsResponse { + version: Some(get_documents_response::Version::V1( + get_documents_response::GetDocumentsResponseV1 { + metadata: Some(case.metadata()), + result: Some( + get_documents_response::get_documents_response_v1::Result::Proof( + case.grpc_proof(), + ), + ), + }, + )), + } +} + +fn run_case(name: &str) { + let case = load_case(name); + let Expected::DocumentsPlaceholder { .. } = &case.manifest.expected else { + panic!("{name}: manifest expectation mismatch"); + }; + let platform_version = case.platform_version(); + let contract = load_system_data_contract(contract_for(&case), platform_version) + .expect("load system data contract"); + let request = v1_request(&case, contract.id().to_vec()); + + // The decoded request must lower to the query the fixture proof was made + // for: the grovedb layer verifies to the pinned root hash. + let wire_query = >::try_from_request(request.clone()) + .expect("decode wire request"); + let drive_query = wire_query + .to_drive_query(&contract, None, platform_version) + .expect("lower to drive query"); + let (root_hash, _) = drive_query + .verify_proof_keep_serialized(&case.grovedb_proof, platform_version) + .unwrap_or_else(|e| panic!("{name}: grovedb layer must verify: {e}")); + assert_eq!( + hex::encode(root_hash), + case.manifest + .expected_root_hash_hex + .as_deref() + .expect("documents cases pin a root hash"), + "{name}: root hash" + ); + + // Through FromProof the placeholder payload fails document decode cleanly. + let error = RequestedDocuments::maybe_from_proof_with_metadata( + request, + response(&case), + NETWORK, + platform_version, + &case.provider(), + ) + .expect_err("placeholder document payload must fail to decode"); + assert!( + matches!( + error, + Error::DriveError { .. } | Error::ProtocolError { .. } + ), + "{name}: expected a document decode error, got: {error:?}" + ); +} + +#[test] +fn dpns_domain_exact_from_wire_request() { + run_case("dpns-domain-exact"); +} + +#[test] +fn dpns_domain_prefix_from_wire_request() { + run_case("dpns-domain-prefix"); +} + +#[test] +fn dashpay_profile_from_wire_request() { + run_case("dashpay-profile"); +} + +#[test] +fn dashpay_contacts_incoming_from_wire_request() { + run_case("dashpay-contacts-incoming"); +} + +/// A v0 request carrying the same query as CBOR decodes to the same drive +/// query, so both wire generations verify against one proof. +#[test] +fn v0_cbor_request_lowers_to_the_same_query() { + let case = load_case("dpns-domain-exact"); + let platform_version = case.platform_version(); + let contract = + load_system_data_contract(SystemDataContract::DPNS, platform_version).expect("dpns"); + let RequestSpec::DocumentsDpnsExact { + normalized_label, + limit, + } = &case.manifest.request + else { + panic!("expected dpns exact"); + }; + let where_value = Value::Array(vec![ + Value::Array(vec![ + Value::Text("normalizedParentDomainName".into()), + Value::Text("==".into()), + Value::Text("dash".into()), + ]), + Value::Array(vec![ + Value::Text("normalizedLabel".into()), + Value::Text("==".into()), + Value::Text(normalized_label.clone()), + ]), + ]); + let request = GetDocumentsRequest { + version: Some(Version::V0(GetDocumentsRequestV0 { + data_contract_id: contract.id().to_vec(), + document_type: "domain".to_string(), + r#where: { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&where_value, &mut bytes).expect("cbor"); + bytes + }, + order_by: vec![], + limit: u32::from(*limit), + prove: true, + start: None, + })), + }; + let wire_query = >::try_from_request(request) + .expect("decode v0 request"); + let drive_query = wire_query + .to_drive_query(&contract, None, platform_version) + .expect("lower"); + let (root_hash, _) = drive_query + .verify_proof_keep_serialized(&case.grovedb_proof, platform_version) + .expect("grovedb layer must verify"); + assert_eq!( + hex::encode(root_hash), + case.manifest.expected_root_hash_hex.as_deref().unwrap() + ); +} + +fn v1(mutate: impl FnOnce(&mut GetDocumentsRequestV1)) -> GetDocumentsRequest { + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let mut request = v1_request(&case, contract.id().to_vec()); + let Some(Version::V1(inner)) = request.version.as_mut() else { + unreachable!() + }; + mutate(inner); + request +} + +fn assert_refused(request: GetDocumentsRequest, needle: &str) { + let case = load_case("dpns-domain-exact"); + let error = RequestedDocuments::maybe_from_proof_with_metadata( + request, + response(&case), + NETWORK, + case.platform_version(), + &case.provider(), + ) + .expect_err("request shape must be refused"); + match &error { + Error::RequestError { error } => assert!( + error.contains(needle), + "expected a rejection mentioning {needle:?}, got: {error}" + ), + other => panic!("expected RequestError, got {other:?}"), + } +} + +#[test] +fn refuses_unproved_request() { + assert_refused(v1(|r| r.prove = false), "prove=false"); +} + +#[test] +fn refuses_aggregate_projection() { + assert_refused( + v1(|r| { + r.selects = vec![ProtoSelect { + function: select::Function::Count as i32, + field: String::new(), + }] + }), + "aggregate projection", + ); +} + +#[test] +fn refuses_group_by_having_and_offset() { + assert_refused(v1(|r| r.group_by = vec!["label".into()]), "GROUP BY"); + assert_refused( + v1(|r| { + r.having = vec![ + dapi_grpc::platform::v0::get_documents_request::HavingClause { + aggregate: None, + operator: 0, + right: None, + }, + ] + }), + "HAVING", + ); + assert_refused(v1(|r| r.offset = Some(3)), "OFFSET"); +} + +#[test] +fn refuses_limit_above_server_cap() { + let case = load_case("dpns-domain-exact"); + let error = RequestedDocuments::maybe_from_proof_with_metadata( + v1(|r| r.limit = Some(101)), + response(&case), + NETWORK, + case.platform_version(), + &case.provider(), + ) + .expect_err("limit above the server cap must be refused"); + assert!( + matches!(error, Error::DriveError { .. }), + "expected the server's InvalidLimit as a drive error, got {error:?}" + ); +} + +#[test] +fn refuses_contract_the_provider_does_not_know() { + assert_refused( + v1(|r| r.data_contract_id = vec![9u8; 32]), + "no data contract", + ); +} + +#[test] +fn refuses_v1_limit_zero_but_normalises_v0_zero_to_default() { + assert_refused(v1(|r| r.limit = Some(0)), "limit = 0"); + + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let request = GetDocumentsRequest { + version: Some(Version::V0(GetDocumentsRequestV0 { + data_contract_id: contract.id().to_vec(), + document_type: "domain".to_string(), + r#where: vec![], + order_by: vec![], + limit: 0, + prove: true, + start: None, + })), + }; + let wire_query = >::try_from_request(request) + .expect("v0 decodes"); + assert_eq!(wire_query.limit, None, "v0 limit 0 is the unset sentinel"); + let drive_query = wire_query + .to_drive_query(&contract, None, case.platform_version()) + .expect("lower"); + assert_eq!( + drive_query.limit, + Some(100), + "unset lowers to the server default" + ); +} + +#[test] +fn refuses_chained_requests() { + assert_refused( + v1(|r| { + r.chained = Some(Default::default()); + }), + "chained", + ); +} + +#[test] +fn cursors_keep_the_server_inclusion_semantics() { + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let decode = |request: GetDocumentsRequest| { + >::try_from_request(request) + }; + let none = decode(v1(|_| {})).expect("no cursor"); + assert_eq!((none.start_at, none.start_at_included), (None, true)); + let at = decode(v1(|r| r.start = Some(V1Start::StartAt(vec![7u8; 32])))).expect("start_at"); + assert_eq!((at.start_at, at.start_at_included), (Some([7u8; 32]), true)); + let after = + decode(v1(|r| r.start = Some(V1Start::StartAfter(vec![8u8; 32])))).expect("start_after"); + assert_eq!( + (after.start_at, after.start_at_included), + (Some([8u8; 32]), false) + ); + decode(v1(|r| r.start = Some(V1Start::StartAt(vec![1u8; 31])))) + .expect_err("a cursor must be 32 bytes"); + let _ = contract; +} + +#[test] +fn refuses_wire_versions_the_platform_version_does_not_serve() { + // Protocol version 1 predates the v1 getDocuments wire entirely. + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let request = v1_request(&case, contract.id().to_vec()); + let old = dpp::version::PlatformVersion::get(1).expect("protocol version 1"); + let error = RequestedDocuments::maybe_from_proof_with_metadata( + request, + response(&case), + NETWORK, + old, + &case.provider(), + ) + .expect_err("v1 wire is not served at protocol version 1"); + match &error { + Error::RequestError { error } => assert!(error.contains("wire version"), "{error}"), + other => panic!("expected RequestError, got {other:?}"), + } +} From 6bd8e28cf041620d3954de04d7eb6182fcfa87a8 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 18:29:34 -0500 Subject: [PATCH 2/2] feat(sdk): pure DPNS and DashPay document builders shared with embedders dash-platform-queries gains build_dpns_preorder_document / build_dpns_domain_document / salted_domain_hash (dpns_usernames) and build_contact_request_document (new dashpay module): the document-assembly halves of dash-sdk's register_dpns_name and create_contact_request as pure functions that take caller-supplied entropy, salt and ciphertexts and touch no network or randomness. dash-sdk's networked flows now call them, so an embedder that assembles its own transitions (the Dash Core platform GUI) and the SDK share one implementation. The builders lean on what the codebase already has rather than re-deriving it: normalization is dpp's consensus convert_to_homograph_safe_chars (the crate's ASCII-only copy, whose non-ASCII behaviour differed from the data trigger's, is replaced by a re-export); the preorder commitment uses dpp::util::hash::hash_double; property names come from the dpns-contract / dashpay-contract constants; and the DashPay byte-array bounds (96 / 48-80 / 38-102) are read from the contract schema instead of being hard-coded. The DPNS domain builder rejects a label the contract's pattern refuses before a preorder is paid for. The entropy/document-id consistency check moves into dpp's DocumentCreateTransitionV0::from_document, where every create-transition caller (SDK, wasm, FFI, embedders) inherits it instead of having to remember an opt-in helper; it refuses with the same InvalidDocumentTransitionIdError Drive would return after the nonce bump. dash-sdk's private ensure_entropy_matches_document_id and its tests are removed in favour of that. API shape (unreleased v4.2-dev): ContactRequestResult now carries the assembled document plus entropy instead of id/owner_id/properties; send_contact_request no longer hand-rebuilds a DocumentV0. --- packages/dash-platform-queries/Cargo.toml | 2 + packages/dash-platform-queries/src/dashpay.rs | 190 +++++++++++++++ .../src/dpns_usernames.rs | 227 ++++++++++++++++-- packages/dash-platform-queries/src/error.rs | 7 + packages/dash-platform-queries/src/lib.rs | 1 + .../v0/from_document.rs | 18 ++ .../document_create_transition/v0/mod.rs | 57 +++++ .../src/platform/dashpay/contact_request.rs | 206 +++++----------- packages/rs-sdk/src/platform/dashpay/mod.rs | 3 + .../rs-sdk/src/platform/dpns_usernames/mod.rs | 113 +-------- .../src/platform/transition/put_document.rs | 101 +------- 11 files changed, 568 insertions(+), 357 deletions(-) create mode 100644 packages/dash-platform-queries/src/dashpay.rs diff --git a/packages/dash-platform-queries/Cargo.toml b/packages/dash-platform-queries/Cargo.toml index f3d76e7c747..7c1bf7ef225 100644 --- a/packages/dash-platform-queries/Cargo.toml +++ b/packages/dash-platform-queries/Cargo.toml @@ -24,6 +24,8 @@ dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ dash-context-provider = { path = "../rs-context-provider", default-features = false } dash-platform-macros = { path = "../rs-dash-platform-macros" } dpp = { path = "../rs-dpp", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", "platform-value-cbor", "state-transitions", "state-transition-validation", diff --git a/packages/dash-platform-queries/src/dashpay.rs b/packages/dash-platform-queries/src/dashpay.rs new file mode 100644 index 00000000000..eb6e10de168 --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,190 @@ +//! Transport-free DashPay document assembly. +//! +//! The Sdk-bound DashPay surface (ECDH, encryption, fetching the recipient, +//! broadcasting) lives in `dash-sdk`; this is the pure DIP-15 document +//! assembly it shares with embedders that hold the encrypted material +//! themselves. Field size bounds come from the DashPay contract schema, so +//! the builder cannot drift from what the contract accepts. + +use crate::dpns_usernames::new_document; +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::errors::DataContractError; +use dpp::document::Document; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::system_data_contracts::dashpay_contract::v1::document_types::contact_request; +use std::collections::BTreeMap; + +/// Inputs of a DIP-15 `contactRequest` document. The encrypted fields are +/// supplied already encrypted (ECDH, AES-CBC with a fresh IV prepended); +/// see `dash-sdk`'s `create_contact_request` for the encryption itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactRequestDocumentParams { + /// Identity sending the request; becomes the document owner. + pub sender_id: Identifier, + /// Identity receiving the request (`toUserId`). + pub recipient_id: Identifier, + /// Index of the sender's encryption key used for ECDH. + pub sender_key_index: u32, + /// Index of the recipient's key used for ECDH. + pub recipient_key_index: u32, + /// DashPay receiving-account reference. + pub account_reference: u32, + /// Encrypted DIP-15 compact extended public key (IV ‖ ciphertext). + pub encrypted_public_key: Vec, + /// Encrypted account label (IV ‖ ciphertext), if any. + pub encrypted_account_label: Option>, + /// Unencrypted auto-accept proof, if any. + pub auto_accept_proof: Option>, + /// Entropy the document id derives from; reuse it on the create + /// transition. + pub entropy: [u8; 32], +} + +/// Assemble a `contactRequest` document, checking every byte-array field +/// against the size bounds the contract schema declares for it. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result { + let document_type = contract.document_type_for_name(contact_request::NAME)?; + + check_byte_field( + document_type, + "encryptedPublicKey", + ¶ms.encrypted_public_key, + )?; + if let Some(label) = ¶ms.encrypted_account_label { + check_byte_field(document_type, "encryptedAccountLabel", label)?; + } + if let Some(proof) = ¶ms.auto_accept_proof { + check_byte_field(document_type, "autoAcceptProof", proof)?; + } + + let mut properties = BTreeMap::from([ + ( + contact_request::properties::TO_USER_ID.to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ), + ( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ), + ( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ), + ( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ), + ( + "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(new_document( + contract, + document_type.name(), + params.sender_id, + params.entropy, + properties, + )) +} + +/// Check `bytes` against the `minItems`/`maxItems` the contract declares for +/// the byte-array property `field`. +fn check_byte_field( + document_type: DocumentTypeRef, + field: &str, + bytes: &[u8], +) -> Result<(), Error> { + let property = document_type.properties().get(field).ok_or_else(|| { + DataContractError::DocumentTypeFieldNotFound(format!( + "{} has no property {field}", + document_type.name() + )) + })?; + let min = property.property_type.min_size().unwrap_or(0) as usize; + let max = property.property_type.max_size().unwrap_or(u16::MAX) as usize; + if bytes.len() < min || bytes.len() > max { + return Err(Error::Config(format!( + "{field} must be {min}-{max} bytes, got {}", + bytes.len() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + fn params(encrypted_public_key: Vec) -> ContactRequestDocumentParams { + ContactRequestDocumentParams { + sender_id: Identifier::from([1u8; 32]), + recipient_id: Identifier::from([2u8; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_public_key, + encrypted_account_label: None, + auto_accept_proof: None, + entropy: [7u8; 32], + } + } + + fn contract() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay contract") + } + + #[test] + fn should_derive_the_document_id_from_the_entropy() { + let contract = contract(); + let document = build_contact_request_document(&contract, params(vec![0u8; 96])) + .expect("valid contact request"); + assert_eq!( + document.id(), + Document::generate_document_id_v0( + &contract.id(), + &Identifier::from([1u8; 32]), + contact_request::NAME, + &[7u8; 32] + ) + ); + assert_eq!(document.owner_id(), Identifier::from([1u8; 32])); + assert_eq!( + document.get("toUserId"), + Some(&Value::Identifier([2u8; 32])) + ); + } + + #[test] + fn should_enforce_the_contract_byte_bounds() { + let contract = contract(); + build_contact_request_document(&contract, params(vec![0u8; 95])) + .expect_err("encryptedPublicKey below the schema's 96 bytes"); + let mut with_label = params(vec![0u8; 96]); + with_label.encrypted_account_label = Some(vec![0u8; 81]); + build_contact_request_document(&contract, with_label) + .expect_err("encryptedAccountLabel above the schema's 80 bytes"); + let mut with_proof = params(vec![0u8; 96]); + with_proof.auto_accept_proof = Some(vec![0u8; 37]); + build_contact_request_document(&contract, with_proof) + .expect_err("autoAcceptProof below the schema's 38 bytes"); + } +} diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index 3f452519b29..87840f3e742 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -2,21 +2,27 @@ //! //! 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. +//! validation/normalization and document assembly shared with embedders. +//! Normalization is dpp's consensus implementation +//! ([`convert_to_homograph_safe_chars`]), the same one the DPNS data trigger +//! checks `normalizedLabel` against. -/// Convert a string to homograph-safe characters by replacing 'o', 'i', and 'l' -/// with '0', '1', and '1' respectively to prevent homograph attacks -pub fn convert_to_homograph_safe_chars(input: &str) -> String { - input - .chars() - .map(|c| match c { - 'o' | 'O' => '0', - 'i' | 'I' => '1', - 'l' | 'L' => '1', - _ => c.to_ascii_lowercase(), - }) - .collect() -} +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::document::{Document, DocumentV0}; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::system_data_contracts::dpns_contract::v1::document_types::domain; +use dpp::util::hash::hash_double; +use std::collections::BTreeMap; + +/// Document type name of the DPNS preorder document. +pub const PREORDER_DOCUMENT_TYPE: &str = "preorder"; +/// The only parent domain names can currently be registered under. +pub const DASH_PARENT_DOMAIN: &str = "dash"; + +pub use dpp::util::strings::convert_to_homograph_safe_chars; /// Check if a username is valid according to DPNS rules /// @@ -97,10 +103,203 @@ pub fn is_contested_username(label: &str) -> bool { .all(|c| matches!(c, 'a'..='z' | '0' | '1' | '-')) } +/// The DPNS `preorder` document that blinds `label`.dash behind `salt`. +/// +/// `saltedDomainHash` is `sha256d(salt ‖ ".dash")` — the +/// same preimage the DPNS data trigger recomputes when the paired `domain` +/// document is created. The document id derives from `entropy`, which must +/// be reused on the create transition. +/// +/// Callers driving their own registration must draw `salt` from a CSPRNG and +/// keep it, the label, and the domain document private until the preorder is +/// confirmed, or the preorder's front-running protection is lost. +pub fn build_dpns_preorder_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + let document_type = contract.document_type_for_name(PREORDER_DOCUMENT_TYPE)?; + let properties = BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(salted_domain_hash(label, salt)), + )]); + Ok(new_document( + contract, + document_type.name(), + owner_id, + entropy, + properties, + )) +} + +/// The DPNS `domain` document registering `label`.dash for `owner_id`, with +/// the identity record pointing at the owner and subdomains disallowed. +/// +/// Rejects a label the DPNS contract's consensus pattern would refuse, so an +/// invalid name fails before a preorder is paid for. `normalizedLabel` is the +/// consensus normalization of `label`. +pub fn build_dpns_domain_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + if !is_valid_username(label) { + return Err(Error::Config(format!( + "DPNS label {label:?} does not match the contract's label pattern" + ))); + } + let document_type = contract.document_type_for_name(domain::NAME)?; + let properties = BTreeMap::from([ + ( + domain::properties::PARENT_DOMAIN_NAME.to_string(), + Value::Text(DASH_PARENT_DOMAIN.to_string()), + ), + ( + domain::properties::NORMALIZED_PARENT_DOMAIN_NAME.to_string(), + Value::Text(DASH_PARENT_DOMAIN.to_string()), + ), + ( + domain::properties::LABEL.to_string(), + Value::Text(label.to_string()), + ), + ( + domain::properties::NORMALIZED_LABEL.to_string(), + Value::Text(convert_to_homograph_safe_chars(label)), + ), + ( + domain::properties::PREORDER_SALT.to_string(), + Value::Bytes32(salt), + ), + ( + domain::properties::RECORDS.to_string(), + Value::Map(vec![( + Value::Text(domain::properties::IDENTITY.to_string()), + Value::Identifier(owner_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]); + Ok(new_document( + contract, + document_type.name(), + owner_id, + entropy, + properties, + )) +} + +/// `sha256d(salt ‖ ".dash")`: the preorder commitment the +/// DPNS data trigger recomputes from the domain document. +pub fn salted_domain_hash(label: &str, salt: [u8; 32]) -> [u8; 32] { + let mut preimage = salt.to_vec(); + preimage.extend_from_slice(convert_to_homograph_safe_chars(label).as_bytes()); + preimage.extend_from_slice(b"."); + preimage.extend_from_slice(DASH_PARENT_DOMAIN.as_bytes()); + hash_double(preimage) +} + +/// A fresh document whose id derives from `entropy`, with every +/// chain-assigned field left unset (they never enter a create transition). +pub(crate) fn new_document( + contract: &DataContract, + document_type_name: &str, + owner_id: Identifier, + entropy: [u8; 32], + properties: BTreeMap, +) -> Document { + Document::V0(DocumentV0 { + id: Document::generate_document_id_v0( + &contract.id(), + &owner_id, + document_type_name, + &entropy, + ), + owner_id, + properties, + ..Default::default() + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn should_build_preorder_and_domain_documents_that_agree() { + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + let owner = Identifier::from([1u8; 32]); + let salt = [5u8; 32]; + let entropy = [9u8; 32]; + + let preorder = build_dpns_preorder_document(&contract, owner, "Alice", salt, entropy) + .expect("preorder"); + let domain = + build_dpns_domain_document(&contract, owner, "Alice", salt, entropy).expect("domain"); + + // The commitment in the preorder is the one the DPNS data trigger + // recomputes from the domain document's salt and normalized label. + assert_eq!( + preorder.get("saltedDomainHash"), + Some(&Value::Bytes32(salted_domain_hash("Alice", salt))) + ); + assert_eq!( + domain.get(domain::properties::NORMALIZED_LABEL), + Some(&Value::Text("a11ce".to_string())) + ); + assert_eq!( + domain.get(domain::properties::LABEL), + Some(&Value::Text("Alice".to_string())) + ); + assert_eq!( + domain.get(domain::properties::PREORDER_SALT), + Some(&Value::Bytes32(salt)) + ); + assert_eq!( + preorder.id(), + Document::generate_document_id_v0( + &contract.id(), + &owner, + PREORDER_DOCUMENT_TYPE, + &entropy + ) + ); + assert_ne!(preorder.id(), domain.id()); + } + + #[test] + fn should_refuse_a_label_the_contract_pattern_rejects() { + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + build_dpns_domain_document( + &contract, + Identifier::from([1u8; 32]), + "-bad", + [0; 32], + [0; 32], + ) + .expect_err("leading hyphen violates the label pattern"); + } + #[test] fn test_convert_to_homograph_safe_chars() { assert_eq!(convert_to_homograph_safe_chars("alice"), "a11ce"); diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs index 0d8727763ce..ecfad70370b 100644 --- a/packages/dash-platform-queries/src/error.rs +++ b/packages/dash-platform-queries/src/error.rs @@ -1,6 +1,7 @@ //! Errors produced by the transport-free query core. use dpp::consensus::ConsensusError; +use dpp::data_contract::errors::DataContractError; use dpp::validation::SimpleConsensusValidationResult; use dpp::ProtocolError; @@ -23,6 +24,12 @@ pub enum Error { Protocol(#[from] ProtocolError), } +impl From for Error { + fn from(value: DataContractError) -> Self { + Self::Protocol(ProtocolError::DataContractError(value)) + } +} + impl From for Error { fn from(value: ConsensusError) -> Self { Self::Protocol(ProtocolError::ConsensusError(Box::new(value))) diff --git a/packages/dash-platform-queries/src/lib.rs b/packages/dash-platform-queries/src/lib.rs index 9da47cf6435..29b71b8f434 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-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs index 126062a1659..cf705c19272 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs @@ -1,3 +1,5 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionIdError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::methods::DocumentTypeV0Methods; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::{Document, DocumentV0Getters}; @@ -18,6 +20,22 @@ impl DocumentCreateTransitionV0 { platform_version: &PlatformVersion, base_feature_version: Option, ) -> Result { + // Drive recomputes the document id from the entropy during + // advanced-structure validation and rejects a mismatch with + // InvalidDocumentTransitionIdError, after the identity contract nonce + // has already been bumped. Refuse locally so no caller can assemble a + // create transition that will only fail once it has been paid for. + let expected_id = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + &entropy, + ); + if document.id() != expected_id { + return Err(ProtocolError::ConsensusError(Box::new( + InvalidDocumentTransitionIdError::new(expected_id, document.id()).into(), + ))); + } let prefunded_voting_balance = document_type.prefunded_voting_balance_for_document(&document, platform_version)?; Ok(DocumentCreateTransitionV0 { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs index 876fc29bc10..3037766d817 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs @@ -600,6 +600,63 @@ mod test { ) } + /// Drive recomputes the document id from the create transition's entropy + /// and rejects a mismatch only after the identity contract nonce was + /// bumped; `from_document` must refuse the same mismatch locally. + #[test] + fn from_document_refuses_an_id_the_entropy_does_not_derive() { + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::document::{Document, DocumentV0}; + + let data_contract = data_contract_with_dynamic_properties(); + let document_type = data_contract + .document_type_for_name("test") + .expect("test document type"); + let owner_id = Identifier::from([2_u8; 32]); + let entropy = [7_u8; 32]; + let derived_id = + Document::generate_document_id_v0(&data_contract.id(), &owner_id, "test", &entropy); + let document = |id: Identifier| { + Document::V0(DocumentV0 { + id, + owner_id, + ..Default::default() + }) + }; + + DocumentCreateTransitionV0::from_document( + document(derived_id), + document_type, + entropy, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ) + .expect("an id derived from the entropy is accepted"); + + let error = DocumentCreateTransitionV0::from_document( + document(Identifier::from([9_u8; 32])), + document_type, + entropy, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ) + .expect_err("an id the entropy does not derive is refused"); + assert!( + matches!( + error, + ProtocolError::ConsensusError(ref boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::InvalidDocumentTransitionIdError(_))) + ), + "unexpected error: {error:?}" + ); + } + #[test] #[cfg(feature = "json-conversion")] fn convert_to_json_with_dynamic_binary_paths() { diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index adf48b41150..bc26966e577 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -5,23 +5,23 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::Document; use crate::{Error, Sdk}; +use dash_platform_queries::dashpay::{ + build_contact_request_document, 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; use dpp::identity::identity_public_key::Purpose; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; -use dpp::platform_value::{Bytes32, Value}; +use dpp::platform_value::Bytes32; use dpp::prelude::Identifier; use platform_encryption::{ derive_shared_key_ecdh, encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN, }; -use std::collections::BTreeMap; /// ECDH provider for contact request encryption /// @@ -105,13 +105,9 @@ pub struct ContactRequestInput { /// Result of creating a contact request document #[derive(Debug)] pub struct ContactRequestResult { - /// The document ID - pub id: Identifier, - /// The owner ID (sender identity ID) - pub owner_id: Identifier, - /// The document properties - pub properties: BTreeMap, - /// The entropy used to derive `id`. + /// The assembled `contactRequest` document (not yet broadcast). + pub document: Document, + /// The entropy used to derive the document id. /// /// This must be reused when broadcasting the document so that the /// document id computed at creation matches the id platform consensus @@ -259,16 +255,6 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided - 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() - ))); - } - } - // Fetch recipient identity if only ID was provided let recipient_identity = match input.recipient { RecipientIdentity::Identity(identity) => identity, @@ -366,27 +352,12 @@ impl Sdk { 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 { 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 @@ -395,66 +366,28 @@ impl Sdk { // 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 - 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)); - } - - // Return the essential fields for the contact request, including the - // entropy that derived `document_id` so the broadcast path can reuse it. - Ok(ContactRequestResult { - id: document_id, - owner_id: sender_id, - properties, - entropy, - }) + let document = build_contact_request_document( + &dashpay_contract, + ContactRequestDocumentParams { + sender_id: input.sender_identity.id().to_owned(), + 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 assembled document together with the entropy that + // derived its id so the broadcast path can reuse it. + Ok(ContactRequestResult { document, entropy }) } /// Send a contact request to the platform @@ -516,30 +449,10 @@ impl Sdk { Error::Generic("DashPay contactRequest document type not found".to_string()) })?; - // Reuse the entropy that derived result.id during creation. Platform - // consensus recomputes the document id from this entropy and rejects the - // create transition unless it matches result.id, so a freshly generated - // entropy here would always be rejected (InvalidDocumentTransitionIdError). + // Reuse the entropy that derived the document id during creation: + // consensus recomputes the id from it and rejects a mismatch. let entropy = result.entropy; - - // Create the document from the result - let document = Document::V0(DocumentV0 { - contract_version: None, - id: result.id, - owner_id: result.owner_id, - properties: result.properties, - 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 document = result.document; // Submit the document to the platform let platform_document = document @@ -634,47 +547,48 @@ mod tests { #[test] fn contact_request_result_entropy_derives_returned_id() { - // Regression for G2 entropy mismatch: the document id returned by - // create_contact_request must be derivable from the entropy carried in - // ContactRequestResult. send_contact_request reuses ContactRequestResult::entropy - // when broadcasting, and platform consensus rejects the create transition - // (InvalidDocumentTransitionIdError) unless - // generate_document_id_v0(contract, owner, "contactRequest", entropy) == base.id. - // - // Without the `entropy` field on ContactRequestResult, - // send_contact_request would generate fresh entropy E2 != E1 and this - // invariant could not even be expressed. This test pins it. + // send_contact_request reuses ContactRequestResult::entropy when + // broadcasting; consensus recomputes the document id from it and + // rejects the create transition on mismatch. Pin that the shared + // builder derives the document id from exactly that entropy. + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + let mut rng = StdRng::seed_from_u64(0x6732_4732); // deterministic, no network let entropy = Bytes32::random_with_rng(&mut rng); - - let contract_id = Identifier::from([1u8; 32]); + let contract = + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay contract"); let owner_id = Identifier::from([2u8; 32]); - let id = Document::generate_document_id_v0( - &contract_id, - &owner_id, - "contactRequest", - entropy.as_slice(), - ); - - let result = ContactRequestResult { - id, - owner_id, - properties: BTreeMap::new(), - entropy, - }; + let document = build_contact_request_document( + &contract, + ContactRequestDocumentParams { + sender_id: owner_id, + recipient_id: Identifier::from([3u8; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_public_key: vec![0u8; 96], + encrypted_account_label: None, + auto_accept_proof: None, + entropy: entropy.0, + }, + ) + .expect("assemble contact request"); + let result = ContactRequestResult { document, entropy }; - // The entropy that send_contact_request will broadcast must regenerate the - // exact id that was returned at creation time. let regenerated = Document::generate_document_id_v0( - &contract_id, - &result.owner_id, + &contract.id(), + &result.document.owner_id(), "contactRequest", result.entropy.as_slice(), ); assert_eq!( - regenerated, result.id, - "entropy carried in ContactRequestResult must derive the returned document id" + regenerated, + result.document.id(), + "entropy carried in ContactRequestResult must derive the document id" ); } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854b..0a771e3ed63 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -12,6 +12,9 @@ pub use contact_request::{ EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; +pub use dash_platform_queries::dashpay::{ + build_contact_request_document, 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 0955ea24454..a6d3c83576d 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_domain_document, build_dpns_preorder_document, convert_to_homograph_safe_chars, + is_contested_username, is_valid_username, salted_domain_hash, }; pub use queries::DpnsUsername; @@ -14,14 +15,12 @@ use dash_context_provider::ContextProvider; 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::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 { @@ -45,14 +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() -} - /// Callback type for preorder document pub type PreorderCallback = Box; @@ -164,97 +155,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 + let preorder_document = build_dpns_preorder_document( + &dpns_contract, + identity_id, + &input.label, + salt, + entropy.0, + )?; + let domain_document = + build_dpns_domain_document(&dpns_contract, identity_id, &input.label, salt, entropy.0)?; 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 { - contract_version: None, - 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 { - contract_version: None, - 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 diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index fa85a30a0dc..e932c0a5a4c 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -11,7 +11,6 @@ use dpp::data_contract::document_type::DocumentType; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; -use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::StateTransition; @@ -88,21 +87,10 @@ impl> PutDocument for Document { } else { let (document, document_state_transition_entropy) = match document_state_transition_entropy { - Some(entropy) => { - // A caller-supplied entropy must derive the document's own id. - // Platform consensus recomputes generate_document_id_v0 from the - // transition entropy and rejects the create with - // InvalidDocumentTransitionIdError on mismatch, so guard here - // before broadcasting to fail locally (no wasted nonce/fee). - ensure_entropy_matches_document_id( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - &entropy, - document.id(), - )?; - (document, entropy) - } + // A caller-supplied entropy must derive the document's own id; + // dpp's DocumentCreateTransition::from_document refuses a + // mismatch locally, before a nonce is bumped. + Some(entropy) => (document, entropy), None => { let mut rng = StdRng::from_entropy(); let mut document = document; @@ -171,45 +159,13 @@ fn prepare_document_for_transition(document: &Document, document_type: &Document document } -/// Ensures a caller-supplied `entropy` derives the same document id already set -/// on a create document. -/// -/// A document-create state transition carries both the document id and the -/// entropy, and Drive recomputes the id from the entropy during -/// `advanced_structure` validation, rejecting the transition with -/// `InvalidDocumentTransitionIdError` when they disagree. Because -/// [`PutDocument::put_to_platform`] trusts the caller's id verbatim in the -/// `Some(entropy)` arm, a two-phase caller whose id and entropy have drifted -/// would only discover the mismatch after paying (a bumped identity-contract -/// nonce). This check surfaces the mismatch locally before broadcasting. -fn ensure_entropy_matches_document_id( - contract_id: &Identifier, - owner_id: &Identifier, - document_type_name: &str, - entropy: &[u8; 32], - document_id: Identifier, -) -> Result<(), Error> { - let expected_id = Document::generate_document_id_v0( - contract_id, - owner_id, - document_type_name, - entropy.as_slice(), - ); - if expected_id != document_id { - return Err(Error::Generic(format!( - "document id {document_id} does not match the id {expected_id} derived from the \ - supplied entropy; the entropy must be the one used to generate the document id" - ))); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use dpp::data_contract::config::DataContractConfig; use dpp::document::DocumentV0; use dpp::platform_value::{platform_value, Value}; + use dpp::prelude::Identifier; use dpp::version::PlatformVersion; use std::collections::BTreeMap; @@ -221,53 +177,6 @@ mod tests { Identifier::from([2u8; 32]) } - #[test] - fn matching_entropy_and_id_pass() { - let entropy = [7u8; 32]; - let id = Document::generate_document_id_v0( - &contract_id(), - &owner_id(), - "contactRequest", - entropy.as_slice(), - ); - - ensure_entropy_matches_document_id( - &contract_id(), - &owner_id(), - "contactRequest", - &entropy, - id, - ) - .expect("id derived from the supplied entropy must be accepted"); - } - - #[test] - fn mismatched_entropy_and_id_error_before_broadcast() { - // The id was derived from E1, but the caller passes E2 != E1 (mirroring - // the very drift consensus rejects with InvalidDocumentTransitionIdError). - let entropy_used = [1u8; 32]; - let id = Document::generate_document_id_v0( - &contract_id(), - &owner_id(), - "contactRequest", - entropy_used.as_slice(), - ); - - let different_entropy = [2u8; 32]; - let result = ensure_entropy_matches_document_id( - &contract_id(), - &owner_id(), - "contactRequest", - &different_entropy, - id, - ); - - assert!( - matches!(result, Err(Error::Generic(_))), - "a document id derived from a different entropy must be rejected locally" - ); - } - #[test] fn should_normalize_wasm_uint8_array_property_without_mutating_caller_document() { let platform_version = PlatformVersion::latest();