diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index d9ea1b64067..441c8137023 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -115,6 +115,9 @@ rs-dapi-client: platform-encryption: - packages/rs-platform-encryption/** +dash-platform-queries: + - packages/dash-platform-queries/** + dash-sdk: - packages/rs-sdk/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 3825b065eef..90835d0429f 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -127,9 +127,13 @@ rs-dapi-client: &dapi_client platform-encryption: &platform_encryption - packages/rs-platform-encryption/** +dash-platform-queries: &platform_queries + - packages/dash-platform-queries/** + dash-sdk: &sdk - packages/rs-drive-proof-verifier/** - packages/rs-sdk/** + - *platform_queries - *dash_async - *context_provider - *sdk_trusted_context_provider diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index f38a77fa931..6fae2aa84ab 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -151,10 +151,15 @@ platform-encryption: &platform_encryption - .github/workflows/tests* - packages/rs-platform-encryption/** +dash-platform-queries: &platform_queries + - .github/workflows/tests* + - packages/dash-platform-queries/** + dash-sdk: &sdk - .github/workflows/tests* - packages/rs-drive-proof-verifier/** - packages/rs-sdk/** + - *platform_queries - *dash_async - *context_provider - *sdk_trusted_context_provider diff --git a/.github/workflows/tests-rs-nightly-long-running.yml b/.github/workflows/tests-rs-nightly-long-running.yml index 5ba1d236fe7..f65b87a37ec 100644 --- a/.github/workflows/tests-rs-nightly-long-running.yml +++ b/.github/workflows/tests-rs-nightly-long-running.yml @@ -20,7 +20,17 @@ jobs: strategy: fail-fast: false matrix: - package: [dash-sdk, rs-dapi-client, rs-dapi, dapi-grpc, dpp, drive-abci] + package: + [ + dash-sdk, + rs-dapi-client, + rs-dapi, + dapi-grpc, + dpp, + drive-abci, + drive-proof-verifier, + dash-platform-queries, + ] steps: - name: Check out repo uses: actions/checkout@v4 diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 8883e9dd3ae..a401766ad90 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -193,6 +193,31 @@ jobs: cargo install cargo-machete 2>/dev/null || true cargo machete + # The transport-free cuts are how embedders with their own networking + # (Dash Core's platform GUI, explorers) consume verification: feature + # unification hides regressions in whole-workspace builds, so check the + # standalone graphs and assert the networking stack stays out of the + # proof-verification tree (native) and out of wasm builds. + - name: Check transport-free feature cuts + run: | + cargo check -p dapi-grpc --no-default-features --features core,platform,client --locked + cargo check -p drive-proof-verifier --locked + cargo check -p dash-platform-queries --locked + for banned in hyper rustls tower; do + if cargo tree -p drive-proof-verifier -e normal -i "$banned" 2>/dev/null | grep -q .; then + echo "::error::$banned leaked into drive-proof-verifier's dependency tree" + exit 1 + fi + done + for banned in hyper rustls tower mio; do + for wasm_package in dash-sdk wasm-sdk; do + if cargo tree -p "$wasm_package" --target wasm32-unknown-unknown -e normal -i "$banned" 2>/dev/null | grep -q .; then + echo "::error::$banned leaked into $wasm_package's wasm32 dependency tree" + exit 1 + fi + done + done + - name: Detect immutable structure changes if: github.event_name == 'pull_request' run: | diff --git a/Cargo.lock b/Cargo.lock index 14ee6074fba..65bbd1828d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1702,6 +1702,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dash-platform-queries" +version = "4.1.0" +dependencies = [ + "ciborium", + "dapi-grpc", + "dash-context-provider", + "dash-platform-macros", + "dpp", + "drive", + "drive-proof-verifier", + "hex", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "dash-sdk" version = "4.1.0" @@ -1719,6 +1737,7 @@ dependencies = [ "dash-context-provider", "dash-network-seeds", "dash-platform-macros", + "dash-platform-queries", "derive_more 1.0.0", "dotenvy", "dpp", @@ -2214,6 +2233,7 @@ dependencies = [ "console-subscriber", "dapi-grpc", "dash-platform-macros", + "dash-platform-queries", "delegate", "derive_more 1.0.0", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index c8d2ff28af0..3e458646beb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "packages/wasm-dpp2", "packages/rs-dapi-client", "packages/rs-dash-async", + "packages/dash-platform-queries", "packages/rs-sdk", "packages/strategy-tests", "packages/simple-signer", diff --git a/packages/check-features/src/main.rs b/packages/check-features/src/main.rs index ce31fce686d..8cba6485410 100644 --- a/packages/check-features/src/main.rs +++ b/packages/check-features/src/main.rs @@ -10,6 +10,7 @@ fn main() { ("rs-drive", vec![]), ("rs-drive-proof-verifier", vec![]), ("rs-platform-wallet", vec![]), + ("dash-platform-queries", vec![]), ]; for (specific_crate, to_ignore) in crates { diff --git a/packages/dapi-grpc/Cargo.toml b/packages/dapi-grpc/Cargo.toml index 498f8584c9d..7fd569f25fd 100644 --- a/packages/dapi-grpc/Cargo.toml +++ b/packages/dapi-grpc/Cargo.toml @@ -26,12 +26,30 @@ tenderdash-proto = [] # Client support. client = ["platform"] +# Networked tonic client: `connect()` on generated clients, TLS roots. +# Deliberately NOT in the default set: cargo features are not target-scoped, +# so a default-on transport would force tonic's transport stack onto wasm32 +# consumers riding defaults, where it does not build. Without this feature +# the crate provides message types and transport-generic client stubs only. +# Tonic's codegen graph still includes tokio-stream and a minimal Tokio +# slice, but its hyper/rustls transport stack is not enabled. Native +# networked consumers enable this explicitly (rs-dapi-client does so through +# its non-wasm dependency); the wasm codegen path never emits `connect()`. +transport = [ + "tonic/channel", + "tonic/transport", + "tonic/tls-native-roots", + "tonic/tls-webpki-roots", + "tonic/tls-ring", +] + # Build tonic server code. Includes all client features and adds server-specific dependencies. server = [ "platform", "tenderdash-proto/server", "client", "drive", + "transport", "tonic/router", ] @@ -55,14 +73,7 @@ tonic = { version = "0.14.2", features = ["codegen"], default-features = false } getrandom = { version = "0.2", features = ["js"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tonic = { version = "0.14.2", features = [ - "codegen", - "channel", - "transport", - "tls-native-roots", - "tls-webpki-roots", - "tls-ring", -], default-features = false } +tonic = { version = "0.14.2", features = ["codegen"], default-features = false } [build-dependencies] tonic-prost-build = { version = "0.14.2" } diff --git a/packages/dapi-grpc/build.rs b/packages/dapi-grpc/build.rs index 50e0d57b6d7..f055da18fc2 100644 --- a/packages/dapi-grpc/build.rs +++ b/packages/dapi-grpc/build.rs @@ -69,6 +69,7 @@ fn generate_code(typ: ImplType, output_base: &Path) { println!("cargo:rerun-if-changed=./protos"); println!("cargo:rerun-if-env-changed=CARGO_FEATURE_SERDE"); + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_TRANSPORT"); println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_ARCH"); println!("cargo:rerun-if-env-changed=DAPI_GRPC_OUT_DIR"); } @@ -416,15 +417,23 @@ enum ImplType { impl ImplType { // Configure the builder based on the implementation type. pub fn configure(&self, builder: Builder) -> Builder { + // The `transport` cargo feature controls whether generated clients get + // the `connect()` convenience impls over tonic's own channel. Without + // it, clients are still generated but stay generic over the caller's + // transport. Never enabled for wasm32, where tonic transport does not + // build. Note: cfg!(target_arch) in a build script reflects the HOST, + // so the target must be read from CARGO_CFG_TARGET_ARCH. + let transport = std::env::var("CARGO_FEATURE_TRANSPORT").is_ok() + && std::env::var("CARGO_CFG_TARGET_ARCH").map(|arch| arch != "wasm32") == Ok(true); match self { Self::Server => builder .build_client(true) .build_server(true) - .build_transport(true), + .build_transport(transport), Self::Client => builder .build_client(true) .build_server(false) - .build_transport(true), + .build_transport(transport), Self::Wasm => builder .build_client(true) .build_server(false) diff --git a/packages/dapi-grpc/src/lib.rs b/packages/dapi-grpc/src/lib.rs index 59967bc5e24..963d6be0cd7 100644 --- a/packages/dapi-grpc/src/lib.rs +++ b/packages/dapi-grpc/src/lib.rs @@ -1,3 +1,22 @@ +//! Protobuf message types and generated gRPC stubs for DAPI. +//! +//! # Feature flags +//! +//! | feature | meaning | +//! |---|---| +//! | `core` / `platform` / `drive` | which proto surfaces are generated | +//! | `client` | generate client stubs (generic over the transport) | +//! | `transport` | tonic's opt-in native transport: `connect()` on generated clients and TLS roots. Pulls the hyper/rustls transport stack. | +//! | `server` | generate server stubs; implies `client`, `drive`, `transport` | +//! | `serde` / `mocks` | serde derives / dump-and-replay support | +//! +//! Types-only consumers (proof verification, embedders with their own +//! transport) build with `default-features = false, features = ["platform", +//! "client"]` and get message types plus transport-generic client stubs with +//! no native networking stack in the dependency tree. The default feature set +//! is also wasm32-safe; native consumers that call generated `connect()` +//! methods must enable `transport` explicitly. + pub use prost::Message; #[cfg(feature = "core")] diff --git a/packages/dash-platform-queries/Cargo.toml b/packages/dash-platform-queries/Cargo.toml new file mode 100644 index 00000000000..7c9870a7392 --- /dev/null +++ b/packages/dash-platform-queries/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "dash-platform-queries" +description = "Transport-free query building and proof decoding core shared by Dash Platform SDK embedders" +version.workspace = true +edition = "2021" +rust-version.workspace = true +license = "MIT" + +[features] +default = [] +mocks = [ + "dep:serde", + "dep:serde_json", + "dapi-grpc/mocks", + "drive/serde", + "dpp/serde-conversion", +] + +[dependencies] +ciborium = { version = "0.2.2" } +dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ + "platform", + "client", +] } +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 = [ + "platform-value-cbor", + "state-transitions", + "state-transition-validation", +] } +drive = { path = "../rs-drive", default-features = false, features = [ + "verify", +] } +drive-proof-verifier = { path = "../rs-drive-proof-verifier", default-features = false } +hex = { version = "0.4.3" } +serde = { version = "1.0.219", default-features = false, features = [ + "rc", +], optional = true } +serde_json = { version = "1.0", optional = true } +thiserror = "2.0.17" +tracing = { version = "0.1.41" } + +[dev-dependencies] +dpp = { path = "../rs-dpp", default-features = false, features = [ + "fixtures-and-mocks", +] } + +[package.metadata.cargo-machete] +ignored = [ + # Used inside the `dash_platform_macros::Mockable` derive expansion under + # the `mocks` feature; machete cannot see through proc-macro output. + "serde_json", +] diff --git a/packages/dash-platform-queries/README.md b/packages/dash-platform-queries/README.md new file mode 100644 index 00000000000..60609fe1b5b --- /dev/null +++ b/packages/dash-platform-queries/README.md @@ -0,0 +1,50 @@ +# dash-platform-queries + +Transport-free query core of the Dash Platform SDK. + +This crate carries the pieces of `dash-sdk` that build queries, encode them +onto the wire format, and decode/verify proved responses — with **no +transport implementation**: no `rs-dapi-client` and no tonic native +channel/TLS stack. Shared generated types and context-provider utilities +remain dependencies. `dash-sdk` depends on it and re-exports everything at +the historical paths, so SDK users need no changes. + +## Who this is for + +Embedders that bring their own transport and trust context and only need the +verification/query layer: + +- **Dash Core's platform GUI** — fetches over its own gRPC-Web transport, + serves quorum keys from its locally synced LLMQ state via a + [`ContextProvider`], and verifies every response proof with + [`drive-proof-verifier`]. +- Block explorers, Electrum-style servers, hardware-wallet tooling — anything + that talks to DAPI its own way but must not trust responses. + +If you want networking, retries, and a managed connection pool, use +`dash-sdk` — it consumes this crate internally. + +## What's here + +- [`documents::DocumentQuery`] — rich document query builder, wire + encoding for both request versions, and decoding **from** the wire request + (`DocumentQuery::try_from_request`) using the same proto conversions the + server (`drive-abci`) uses, so client and server cannot drift. +- `documents::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`. +- 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` and document-transition helpers + (`ensure_entropy_matches_document_id`, `prepare_document_for_transition`). + +## Feature flags + +- `mocks` — serde support for the types used in dump/replay test vectors + (forwarded by `dash-sdk`'s `mocks`). + +The dependency tree is checked in CI to stay free of the transport stack +(`hyper`, `rustls`, `tower`); see the "Check transport-free feature cuts" +step in `.github/workflows/tests-rs-workspace.yml`. diff --git a/packages/rs-sdk/src/platform/block_info_from_metadata.rs b/packages/dash-platform-queries/src/block_info_from_metadata.rs similarity index 100% rename from packages/rs-sdk/src/platform/block_info_from_metadata.rs rename to packages/dash-platform-queries/src/block_info_from_metadata.rs diff --git a/packages/dash-platform-queries/src/dashpay.rs b/packages/dash-platform-queries/src/dashpay.rs new file mode 100644 index 00000000000..6690237830f --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,294 @@ +//! 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)) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + fn dashpay_contract() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("should load DashPay system contract") + } + + fn valid_params() -> ContactRequestDocumentParams { + ContactRequestDocumentParams { + sender_id: Identifier::from([2u8; 32]), + recipient_id: Identifier::from([3u8; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 7, + encrypted_public_key: vec![0xAA; 96], + encrypted_account_label: None, + auto_accept_proof: None, + entropy: [5u8; 32], + } + } + + #[test] + fn entropy_derives_built_document_id() { + // Mirror of rs-sdk's contact_request_result_entropy_derives_returned_id: + // the id the builder returns must be exactly what consensus recomputes + // from the entropy attached to the create transition. + let contract = dashpay_contract(); + let params = valid_params(); + let entropy = params.entropy; + let sender_id = params.sender_id; + + let (id, _) = + build_contact_request_document(&contract, params).expect("valid params must build"); + + assert_eq!( + id, + Document::generate_document_id_v0( + &contract.id(), + &sender_id, + "contactRequest", + entropy.as_slice() + ), + "built document id must derive from the supplied entropy" + ); + } + + #[test] + fn builds_expected_property_map() { + let contract = dashpay_contract(); + let mut params = valid_params(); + params.encrypted_account_label = Some(vec![0xBB; 48]); + params.auto_accept_proof = Some(vec![0xCC; 38]); + + let (_, properties) = + build_contact_request_document(&contract, params).expect("valid params must build"); + + assert_eq!( + properties, + BTreeMap::from([ + ( + "toUserId".to_string(), + Value::Identifier(Identifier::from([3u8; 32]).to_buffer()) + ), + ( + "encryptedPublicKey".to_string(), + Value::Bytes(vec![0xAA; 96]) + ), + ("senderKeyIndex".to_string(), Value::U32(1)), + ("recipientKeyIndex".to_string(), Value::U32(2)), + ("accountReference".to_string(), Value::U32(7)), + ( + "encryptedAccountLabel".to_string(), + Value::Bytes(vec![0xBB; 48]) + ), + ("autoAcceptProof".to_string(), Value::Bytes(vec![0xCC; 38])), + ]) + ); + } + + #[test] + fn optional_fields_are_omitted_when_absent() { + let contract = dashpay_contract(); + let (_, properties) = build_contact_request_document(&contract, valid_params()) + .expect("valid params must build"); + + assert_eq!(properties.len(), 5); + assert!(!properties.contains_key("encryptedAccountLabel")); + assert!(!properties.contains_key("autoAcceptProof")); + } + + #[test] + fn rejects_wrong_encrypted_public_key_size() { + let contract = dashpay_contract(); + for bad_len in [0, 95, 97] { + let mut params = valid_params(); + params.encrypted_public_key = vec![0xAA; bad_len]; + assert!( + matches!( + build_contact_request_document(&contract, params), + Err(Error::InvalidInput(_)) + ), + "encrypted public key of {bad_len} bytes must be rejected" + ); + } + } + + #[test] + fn rejects_out_of_range_auto_accept_proof() { + let contract = dashpay_contract(); + for bad_len in [0, 37, 103] { + let mut params = valid_params(); + params.auto_accept_proof = Some(vec![0xCC; bad_len]); + assert!( + matches!( + build_contact_request_document(&contract, params), + Err(Error::InvalidInput(_)) + ), + "auto accept proof of {bad_len} bytes must be rejected" + ); + } + for good_len in [38, 70, 102] { + let mut params = valid_params(); + params.auto_accept_proof = Some(vec![0xCC; good_len]); + assert!( + build_contact_request_document(&contract, params).is_ok(), + "auto accept proof of {good_len} bytes must be accepted" + ); + } + } + + #[test] + fn rejects_out_of_range_encrypted_account_label() { + let contract = dashpay_contract(); + for bad_len in [0, 47, 81] { + let mut params = valid_params(); + params.encrypted_account_label = Some(vec![0xBB; bad_len]); + assert!( + matches!( + build_contact_request_document(&contract, params), + Err(Error::InvalidInput(_)) + ), + "encrypted account label of {bad_len} bytes must be rejected" + ); + } + } +} diff --git a/packages/rs-sdk/src/platform/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs similarity index 99% rename from packages/rs-sdk/src/platform/documents/average_proof_helpers.rs rename to packages/dash-platform-queries/src/documents/average_proof_helpers.rs index ec73d18fba0..5ab4e9bf77a 100644 --- a/packages/rs-sdk/src/platform/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -19,7 +19,7 @@ //! [`DocumentAverage`]: drive_proof_verifier::DocumentAverage //! [`DocumentSplitAverages`]: drive_proof_verifier::DocumentSplitAverages -use crate::platform::documents::document_query::DocumentQuery; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProvider; diff --git a/packages/rs-sdk/src/platform/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs similarity index 99% rename from packages/rs-sdk/src/platform/documents/count_proof_helpers.rs rename to packages/dash-platform-queries/src/documents/count_proof_helpers.rs index 9b99fcdee48..e19e9074f53 100644 --- a/packages/rs-sdk/src/platform/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -13,7 +13,7 @@ //! [`DocumentCount`]: drive_proof_verifier::DocumentCount //! [`DocumentSplitCounts`]: drive_proof_verifier::DocumentSplitCounts -use crate::platform::documents::document_query::DocumentQuery; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProvider; diff --git a/packages/rs-sdk/src/platform/documents/document_average.rs b/packages/dash-platform-queries/src/documents/document_average.rs similarity index 96% rename from packages/rs-sdk/src/platform/documents/document_average.rs rename to packages/dash-platform-queries/src/documents/document_average.rs index 340b7ca1a4e..9a24a365b77 100644 --- a/packages/rs-sdk/src/platform/documents/document_average.rs +++ b/packages/dash-platform-queries/src/documents/document_average.rs @@ -13,11 +13,8 @@ //! absent branch — same forward-compat for absence proofs as count) //! contribute 0 to both axes via `filter_map(|e| e.)`. -use crate::platform::documents::average_proof_helpers::{ - assert_select_is_avg, verify_average_query, -}; -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::Fetch; +use crate::documents::average_proof_helpers::{assert_select_is_avg, verify_average_query}; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -101,11 +98,6 @@ impl FromProof for DocumentAverage { } } -impl Fetch for DocumentAverage { - type Query = super::document_query::DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} - #[cfg(test)] mod tests { //! Unit tests for the AVG fold. The fold logic is extracted diff --git a/packages/rs-sdk/src/platform/documents/document_count.rs b/packages/dash-platform-queries/src/documents/document_count.rs similarity index 85% rename from packages/rs-sdk/src/platform/documents/document_count.rs rename to packages/dash-platform-queries/src/documents/document_count.rs index 8f46f8c9c90..1ea5899ebd8 100644 --- a/packages/rs-sdk/src/platform/documents/document_count.rs +++ b/packages/dash-platform-queries/src/documents/document_count.rs @@ -13,9 +13,8 @@ //! queried-but-absent branch) contribute 0 to the sum via //! `filter_map(|e| e.count)`. -use crate::platform::documents::count_proof_helpers::{assert_select_is_count, verify_count_query}; -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::Fetch; +use crate::documents::count_proof_helpers::{assert_select_is_count, verify_count_query}; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -47,8 +46,3 @@ impl FromProof for DocumentCount { Ok((count, mtd, proof)) } } - -impl Fetch for DocumentCount { - type Query = DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} diff --git a/packages/rs-sdk/src/platform/documents/document_history_query.rs b/packages/dash-platform-queries/src/documents/document_history_query.rs similarity index 100% rename from packages/rs-sdk/src/platform/documents/document_history_query.rs rename to packages/dash-platform-queries/src/documents/document_history_query.rs diff --git a/packages/rs-sdk/src/platform/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs similarity index 75% rename from packages/rs-sdk/src/platform/documents/document_query.rs rename to packages/dash-platform-queries/src/documents/document_query.rs index 57648cd1988..e0ea6f34e78 100644 --- a/packages/rs-sdk/src/platform/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -2,8 +2,8 @@ use std::sync::Arc; -use crate::platform::Fetch; -use crate::{error::Error, sdk::Sdk}; +use super::proto_conversions; +use crate::error::Error; use dapi_grpc::platform::v0::get_documents_request::Version::{V0, V1}; use dapi_grpc::platform::v0::{ self as platform_proto, @@ -179,25 +179,6 @@ impl DocumentQuery { Self::from(d) } - /// Create new document query for provided document type name and data contract ID. - /// - /// Note that this method will fetch data contract first. - pub async fn new_with_data_contract_id( - api: &Sdk, - data_contract_id: Identifier, - document_type_name: &str, - ) -> Result { - let data_contract = - DataContract::fetch(api, data_contract_id) - .await? - .ok_or(Error::MissingDependency( - "DataContract".to_string(), - format!("data contract {} not found", data_contract_id), - ))?; - - Self::new(data_contract, document_type_name) - } - /// Point to a specific document ID. pub fn with_document_id(self, document_id: &Identifier) -> Self { let clause = WhereClause { @@ -317,7 +298,7 @@ impl DocumentQuery { /// /// # The 5th-best group /// - /// ```rust,no_run + /// ```rust,ignore /// # use dash_sdk::platform::{DataContract, DocumentQuery}; /// # use dash_sdk::platform::documents::document_query::RankingDirection; /// # use dash_sdk::drive::query::SelectProjection; @@ -386,6 +367,363 @@ 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 + /// 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: + /// - **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, so this only matters for fabricated + /// request/response pairs. + 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(), + ))), + } +} + +/// 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`], on the same shared decoders +/// the server runs) 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. +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> { + 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}"), + } + })?; + // This entry point 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 Err(drive_proof_verifier::Error::RequestError { + error: format!( + "verify_documents_response only verifies plain document fetches; the request \ + carries a {:?} projection — use the aggregate proof helpers instead", + query.select.function + ), + }); + } + >::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> { + 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 { @@ -1073,20 +1411,3 @@ fn value_to_proto_at_depth(value: Value, depth: u8) -> Result for DocumentQuery { - fn query( - &self, - settings: &crate::platform::QuerySettings<'_>, - ) -> Result { - GetDocumentsRequest::try_from_platform_versioned(self.clone(), settings.protocol_version) - } -} diff --git a/packages/rs-sdk/src/platform/documents/document_ranked_entries.rs b/packages/dash-platform-queries/src/documents/document_ranked_entries.rs similarity index 97% rename from packages/rs-sdk/src/platform/documents/document_ranked_entries.rs rename to packages/dash-platform-queries/src/documents/document_ranked_entries.rs index b0cd0d41f95..544a5c00541 100644 --- a/packages/rs-sdk/src/platform/documents/document_ranked_entries.rs +++ b/packages/dash-platform-queries/src/documents/document_ranked_entries.rs @@ -80,7 +80,7 @@ //! //! `SELECT AVG(grade) GROUP BY restaurantId ORDER BY avg(grade) DESC LIMIT 5` //! -//! ```rust,no_run +//! ```rust,ignore //! use dash_sdk::{Sdk, platform::{DataContract, DocumentQuery, Fetch, Identifier}}; //! use dash_sdk::drive::query::SelectProjection; //! use dash_sdk::platform::documents::document_query::RankingDirection; @@ -127,7 +127,7 @@ //! //! `SELECT AVG(grade) GROUP BY restaurantId ORDER BY avg(grade) DESC LIMIT 1 OFFSET 4` //! -//! ```rust,no_run +//! ```rust,ignore //! # use dash_sdk::platform::{DataContract, DocumentQuery}; //! # use dash_sdk::platform::documents::document_query::RankingDirection; //! # use dash_sdk::drive::query::SelectProjection; @@ -142,9 +142,8 @@ //! # } //! ``` -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::documents::ranked_proof_helpers::verify_ranked_query; -use crate::platform::Fetch; +use crate::documents::document_query::DocumentQuery; +use crate::documents::ranked_proof_helpers::verify_ranked_query; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -178,11 +177,6 @@ impl FromProof for DocumentRankedEntries { } } -impl Fetch for DocumentRankedEntries { - type Query = DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} - #[cfg(test)] mod tests { //! Offline tests for the ranked client surface: the ordering @@ -201,8 +195,8 @@ mod tests { //! not exist offline. use super::*; - use crate::platform::documents::document_query::RankingDirection; - use crate::platform::documents::ranked_proof_helpers::assert_ranked_shape; + use crate::documents::document_query::RankingDirection; + use crate::documents::ranked_proof_helpers::assert_ranked_shape; use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::select as proto_select; use dapi_grpc::platform::v0::get_documents_request::{ order_clause, GetDocumentsRequestV1, OrderClause as ProtoOrderClause, diff --git a/packages/rs-sdk/src/platform/documents/document_split_averages.rs b/packages/dash-platform-queries/src/documents/document_split_averages.rs similarity index 85% rename from packages/rs-sdk/src/platform/documents/document_split_averages.rs rename to packages/dash-platform-queries/src/documents/document_split_averages.rs index f15f1695151..29a3ab9252d 100644 --- a/packages/rs-sdk/src/platform/documents/document_split_averages.rs +++ b/packages/dash-platform-queries/src/documents/document_split_averages.rs @@ -12,11 +12,8 @@ //! impl passes the verified entries through unchanged, mapping //! `AverageEntry` to `SplitAverageEntry`. -use crate::platform::documents::average_proof_helpers::{ - assert_select_is_avg, verify_average_query, -}; -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::Fetch; +use crate::documents::average_proof_helpers::{assert_select_is_avg, verify_average_query}; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -57,8 +54,3 @@ impl FromProof for DocumentSplitAverages { Ok((split, mtd, proof)) } } - -impl Fetch for DocumentSplitAverages { - type Query = super::document_query::DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} diff --git a/packages/rs-sdk/src/platform/documents/document_split_counts.rs b/packages/dash-platform-queries/src/documents/document_split_counts.rs similarity index 89% rename from packages/rs-sdk/src/platform/documents/document_split_counts.rs rename to packages/dash-platform-queries/src/documents/document_split_counts.rs index 79fb3455354..18eb2ab664f 100644 --- a/packages/rs-sdk/src/platform/documents/document_split_counts.rs +++ b/packages/dash-platform-queries/src/documents/document_split_counts.rs @@ -31,9 +31,8 @@ //! ranges are simply absent — the range itself is unbounded so //! there's no enumerable key set to ever-emit. -use crate::platform::documents::count_proof_helpers::{assert_select_is_count, verify_count_query}; -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::Fetch; +use crate::documents::count_proof_helpers::{assert_select_is_count, verify_count_query}; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -62,8 +61,3 @@ impl FromProof for DocumentSplitCounts { Ok((entries.map(DocumentSplitCounts::from_verified), mtd, proof)) } } - -impl Fetch for DocumentSplitCounts { - type Query = DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} diff --git a/packages/rs-sdk/src/platform/documents/document_split_sums.rs b/packages/dash-platform-queries/src/documents/document_split_sums.rs similarity index 85% rename from packages/rs-sdk/src/platform/documents/document_split_sums.rs rename to packages/dash-platform-queries/src/documents/document_split_sums.rs index fc5d1203304..50cdd6943ae 100644 --- a/packages/rs-sdk/src/platform/documents/document_split_sums.rs +++ b/packages/dash-platform-queries/src/documents/document_split_sums.rs @@ -12,9 +12,8 @@ //! passes the verified entries through unchanged, mapping //! `SumEntry` to `SplitSumEntry`. -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::documents::sum_proof_helpers::{assert_select_is_sum, verify_sum_query}; -use crate::platform::Fetch; +use crate::documents::document_query::DocumentQuery; +use crate::documents::sum_proof_helpers::{assert_select_is_sum, verify_sum_query}; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -54,8 +53,3 @@ impl FromProof for DocumentSplitSums { Ok((split, mtd, proof)) } } - -impl Fetch for DocumentSplitSums { - type Query = super::document_query::DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} diff --git a/packages/rs-sdk/src/platform/documents/document_sum.rs b/packages/dash-platform-queries/src/documents/document_sum.rs similarity index 95% rename from packages/rs-sdk/src/platform/documents/document_sum.rs rename to packages/dash-platform-queries/src/documents/document_sum.rs index c4265ec9ece..d88d57f530d 100644 --- a/packages/rs-sdk/src/platform/documents/document_sum.rs +++ b/packages/dash-platform-queries/src/documents/document_sum.rs @@ -18,9 +18,8 @@ //! can switch to `DocumentSplitSums` (which preserves per-branch //! `i64`s and lets the caller pick its own arithmetic). -use crate::platform::documents::document_query::DocumentQuery; -use crate::platform::documents::sum_proof_helpers::{assert_select_is_sum, verify_sum_query}; -use crate::platform::Fetch; +use crate::documents::document_query::DocumentQuery; +use crate::documents::sum_proof_helpers::{assert_select_is_sum, verify_sum_query}; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; @@ -86,11 +85,6 @@ impl FromProof for DocumentSum { } } -impl Fetch for DocumentSum { - type Query = super::document_query::DocumentQuery; - type Request = dapi_grpc::platform::v0::GetDocumentsRequest; -} - #[cfg(test)] mod tests { //! Unit tests for the SUM fold. The fold logic is extracted diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs new file mode 100644 index 00000000000..6015390cfbf --- /dev/null +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -0,0 +1,31 @@ +pub(crate) mod average_proof_helpers; +pub(crate) mod count_proof_helpers; +/// `FromProof` impl for the average-side aggregate result. Returns +/// `(count, sum)`; client divides. +pub mod document_average; +pub mod document_count; +pub mod document_history_query; +pub mod document_query; +/// `FromProof` impl for the ranked (`GROUP BY … ORDER BY LIMIT n +/// [OFFSET m]`) result — one entry per returned group, in ranking order, +/// plus the rank the page starts at. Requires an index declaring +/// `rankedCountable` / `rankedSummable` / `rankedAverageable` +/// (protocol version 14+). +pub mod document_ranked_entries; +/// `FromProof` impl for the average-side per-entry result. Mirrors +/// `document_split_sums`. +pub mod document_split_averages; +pub mod document_split_counts; +/// `FromProof` impl for the sum-side per-entry result. Mirrors +/// `document_split_counts`. +pub mod document_split_sums; +/// `FromProof` impl for the sum-side aggregate result. Mirrors +/// `document_count`. Lights up alongside grovedb PR 670. +pub mod document_sum; +/// Shared wire-proto → drive-type decoders for `getDocuments`, +/// used by both rs-drive-abci (server request decode) and +/// [`document_query::DocumentQuery::try_from_request`] (client +/// verification) so the two directions cannot drift. +pub 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 00000000000..a368a38e7b7 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/proto_conversions.rs @@ -0,0 +1,372 @@ +//! Wire-protobuf → drive type conversions for the `getDocuments` +//! query surface. +//! +//! This is the **single** proto-decode implementation, shared by: +//! - rs-drive-abci's v1 request handler (server side — decodes the +//! incoming request before routing/execution), and +//! - [`DocumentQuery::try_from_request`](super::document_query::DocumentQuery::try_from_request) +//! (client side — rebuilds the rich query from the wire request so +//! a proved response can be verified against exactly what was +//! asked). +//! +//! Both directions living on one implementation is the point: 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. +//! +//! 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). rs-drive-abci maps these onto its +//! `QueryError::InvalidArgument` / `QuerySyntaxError::Unsupported` +//! respectively, preserving its historical 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 shared proto → drive conversions. +/// +/// Deliberately not a server or client error type: rs-drive-abci +/// maps it onto its `QueryError`, and the client-side +/// `DocumentQuery` decoding maps it onto the crate +/// [`Error`](crate::error::Error), each preserving its own error +/// surface. +#[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 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 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 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 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 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, + }) +} diff --git a/packages/rs-sdk/src/platform/documents/ranked_proof_helpers.rs b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs similarity index 99% rename from packages/rs-sdk/src/platform/documents/ranked_proof_helpers.rs rename to packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs index 9211efbce89..2b201ebc092 100644 --- a/packages/rs-sdk/src/platform/documents/ranked_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs @@ -18,7 +18,7 @@ //! //! [`DocumentRankedEntries`]: drive_proof_verifier::DocumentRankedEntries -use crate::platform::documents::document_query::DocumentQuery; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProvider; diff --git a/packages/rs-sdk/src/platform/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs similarity index 99% rename from packages/rs-sdk/src/platform/documents/sum_proof_helpers.rs rename to packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index 17005e06301..fadfd00cfd8 100644 --- a/packages/rs-sdk/src/platform/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -19,7 +19,7 @@ //! [`DocumentSum`]: drive_proof_verifier::DocumentSum //! [`DocumentSplitSums`]: drive_proof_verifier::DocumentSplitSums -use crate::platform::documents::document_query::DocumentQuery; +use crate::documents::document_query::DocumentQuery; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProvider; diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs new file mode 100644 index 00000000000..14d933e572e --- /dev/null +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -0,0 +1,486 @@ +//! Transport-free DPNS username helpers. +//! +//! The Sdk-bound DPNS surface (registration, availability checks, name +//! 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`. +/// +/// 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 +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() +} + +/// 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 +/// +/// * `label` - The username label to check (e.g., "alice") +/// +/// # Returns +/// +/// Returns `true` if the username is valid, `false` otherwise +pub fn is_valid_username(label: &str) -> bool { + is_consensus_valid_label(label) && !label.contains("--") +} + +/// Check if a username is contested (requires masternode voting) +/// +/// A username is contested if its normalized label: +/// - Is between 3 and 19 characters long (inclusive) +/// - Contains only lowercase letters a-z, digits 0-1, and hyphens +/// +/// # Arguments +/// +/// * `label` - The username label to check (e.g., "alice") +/// +/// # Returns +/// +/// Returns `true` if the username would be contested, `false` otherwise +pub fn is_contested_username(label: &str) -> bool { + let normalized = convert_to_homograph_safe_chars(label); + + // Check length + if normalized.len() < 3 || normalized.len() > 19 { + return false; + } + + // Check if all characters match the pattern [a-z01-] + normalized + .chars() + .all(|c| matches!(c, 'a'..='z' | '0' | '1' | '-')) +} + +#[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 dpns_contract() -> DataContract { + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("should load DPNS system contract") + } + + #[test] + fn build_dpns_documents_known_vector() { + // Fixed (label, entropy, salt) must always produce the same document + // ids and property maps: platform consensus recomputes the ids from + // the entropy, and the resolved name matches on these exact fields. + let contract = dpns_contract(); + let identity_id = Identifier::from([2u8; 32]); + let entropy = [3u8; 32]; + let salt = [4u8; 32]; + + let (preorder, domain) = build_dpns_preorder_and_domain_documents( + &contract, + identity_id, + "Alice", + entropy, + salt, + ) + .expect("valid label must build"); + + // Pinned vectors: any drift in the id derivation (contract id, owner, + // type name, entropy layout) or the salted-hash preimage + // (salt ‖ "a11ce.dash", double SHA256) changes these values. + assert_eq!( + preorder + .id() + .to_string(dpp::platform_value::string_encoding::Encoding::Base58), + "8orwov4SyqCiCppTiEogdtFHSpGyPJUfR4MtHZW8mPBB" + ); + assert_eq!( + domain + .id() + .to_string(dpp::platform_value::string_encoding::Encoding::Base58), + "CeNRVgX6wseeTeoiJEspAJstmACSV57VfsRjHChDh5ec" + ); + + // Both ids derive from the SAME entropy (only the document type name + // differs), which is what lets one entropy drive both create + // transitions. + assert_eq!( + preorder.id(), + Document::generate_document_id_v0( + &contract.id(), + &identity_id, + "preorder", + entropy.as_slice() + ) + ); + assert_eq!( + domain.id(), + Document::generate_document_id_v0( + &contract.id(), + &identity_id, + "domain", + entropy.as_slice() + ) + ); + assert_eq!(preorder.owner_id(), identity_id); + assert_eq!(domain.owner_id(), identity_id); + assert_eq!(preorder.revision(), None); + assert_eq!(domain.revision(), None); + + // saltedDomainHash = sha256d(salt ‖ "a11ce.dash"), pinned as a vector. + let expected_hash: [u8; 32] = + hex::decode("5396e080af450f80f4f8ddbfc3eb0a885674c9cf6edbea815dad7305b558e253") + .expect("valid hex") + .try_into() + .expect("32 bytes"); + assert_eq!( + preorder.properties(), + &BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(expected_hash) + )]) + ); + + assert_eq!( + domain.properties(), + &BTreeMap::from([ + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()) + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()) + ), + ("label".to_string(), Value::Text("Alice".to_string())), + ( + "normalizedLabel".to_string(), + Value::Text("a11ce".to_string()) + ), + ("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) + )]) + ), + ]) + ); + } + + #[test] + fn build_dpns_documents_rejects_invalid_label() { + let contract = dpns_contract(); + let identity_id = Identifier::from([2u8; 32]); + + for bad in ["", "ab", "-alice", "alice-", "alice_bob"] { + let result = build_dpns_preorder_and_domain_documents( + &contract, + identity_id, + bad, + [3u8; 32], + [4u8; 32], + ); + assert!( + matches!(result, Err(Error::InvalidInput(_))), + "label {bad:?} must be rejected" + ); + } + } + + /// Consecutive hyphens are consensus-valid (the DPNS contract pattern + /// `^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$` allows them), so the + /// builder must accept them even though the stricter client-side + /// [`is_valid_username`] policy rejects them. + #[test] + fn build_dpns_documents_accepts_consensus_valid_double_hyphen() { + let contract = dpns_contract(); + let identity_id = Identifier::from([2u8; 32]); + + assert!(is_consensus_valid_label("alice--bob")); + assert!(!is_valid_username("alice--bob")); + build_dpns_preorder_and_domain_documents( + &contract, + identity_id, + "alice--bob", + [3u8; 32], + [4u8; 32], + ) + .expect("consensus-valid label with consecutive hyphens must build"); + } + + #[test] + fn test_convert_to_homograph_safe_chars() { + assert_eq!(convert_to_homograph_safe_chars("alice"), "a11ce"); + assert_eq!(convert_to_homograph_safe_chars("bob"), "b0b"); + assert_eq!(convert_to_homograph_safe_chars("COOL"), "c001"); + assert_eq!(convert_to_homograph_safe_chars("test123"), "test123"); + } + + #[test] + fn test_is_valid_username() { + // Valid usernames + assert!(is_valid_username("abc")); + assert!(is_valid_username("alice")); + assert!(is_valid_username("Alice123")); + assert!(is_valid_username("dash-p2p")); + assert!(is_valid_username("test-name-123")); + assert!(is_valid_username("a-b-c")); + assert!(is_valid_username("user2024")); + assert!(is_valid_username("CryptoKing")); + assert!(is_valid_username("web3-developer")); + assert!(is_valid_username("a".repeat(63).as_str())); // Max length + + // Invalid - too short + assert!(!is_valid_username("ab")); + assert!(!is_valid_username("a")); + assert!(!is_valid_username("")); + + // Invalid - too long + assert!(!is_valid_username("a".repeat(64).as_str())); + + // Invalid - starts with hyphen + assert!(!is_valid_username("-alice")); + assert!(!is_valid_username("-test")); + + // Invalid - ends with hyphen + assert!(!is_valid_username("alice-")); + assert!(!is_valid_username("test-")); + + // Invalid - starts and ends with hyphen + assert!(!is_valid_username("-alice-")); + + // Invalid - contains invalid characters + assert!(!is_valid_username("alice_bob")); // underscore + assert!(!is_valid_username("alice.bob")); // dot + assert!(!is_valid_username("alice@dash")); // at sign + assert!(!is_valid_username("alice!")); // exclamation + assert!(!is_valid_username("alice bob")); // space + assert!(!is_valid_username("alice#1")); // hash + assert!(!is_valid_username("alice$")); // dollar + assert!(!is_valid_username("alice%20")); // percent + + // Invalid - consecutive hyphens + assert!(!is_valid_username("alice--bob")); + assert!(!is_valid_username("test---name")); + } + + #[test] + fn test_is_contested_username() { + // Contested usernames (3-19 chars, only [a-z01-]) + assert!(is_contested_username("abc")); + assert!(is_contested_username("alice")); // becomes "a11ce" + assert!(is_contested_username("b0b")); + assert!(is_contested_username("cool")); // becomes "c001" + assert!(is_contested_username("a-b-c")); + assert!(is_contested_username("hello")); // becomes "he110" + assert!(is_contested_username("world")); // becomes "w0r1d" + assert!(is_contested_username("dash")); + assert!(is_contested_username("a11ce")); // already normalized + assert!(is_contested_username("dash-dao")); // becomes "dash-da0" + + // Not contested - too short + assert!(!is_contested_username("ab")); + assert!(!is_contested_username("io")); // becomes "10" which is 2 chars + assert!(!is_contested_username("a")); + + // Not contested - too long (20+ chars) + assert!(!is_contested_username("twenty-characters-ab")); // 20 chars + assert!(!is_contested_username( + "this-is-a-very-long-username-that-exceeds-limit" + )); + + // Not contested - contains invalid characters after normalization + assert!(!is_contested_username("alice2")); // contains '2' + assert!(!is_contested_username("alice_bob")); // contains '_' + assert!(!is_contested_username("alice.bob")); // contains '.' + assert!(!is_contested_username("alice@dash")); // contains '@' + assert!(!is_contested_username("alice!")); // contains '!' + assert!(!is_contested_username("test123")); // contains '2' and '3' + assert!(!is_contested_username("dash-p2p")); // contains 'p' and '2' + assert!(!is_contested_username("user5")); // contains '5' + assert!(!is_contested_username("name_with_underscore")); // contains '_' + } +} diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs new file mode 100644 index 00000000000..f30573c3a72 --- /dev/null +++ b/packages/dash-platform-queries/src/error.rs @@ -0,0 +1,67 @@ +//! Errors produced by the transport-free query core. + +use dpp::consensus::ConsensusError; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::ProtocolError; + +/// Error type for the transport-free query core. +/// +/// `dash-sdk` converts this into its own `Error` via `From`, so code that +/// moved here from the SDK keeps working behind `?` at its old call sites. +// Same allowance rs-sdk's Error carries: ProtocolError dominates the size. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, thiserror::Error)] +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), + /// DPP error + #[error("Protocol error: {0}")] + 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))) + } +} + +impl From for Error { + fn from(value: SimpleConsensusValidationResult) -> Self { + value + .errors + .into_iter() + .next() + .map(Error::from) + .unwrap_or_else(|| { + Error::Protocol(ProtocolError::CorruptedCodeExecution( + "state transition structure validation failed without an error".to_string(), + )) + }) + } +} diff --git a/packages/dash-platform-queries/src/lib.rs b/packages/dash-platform-queries/src/lib.rs new file mode 100644 index 00000000000..474f7cbeeb4 --- /dev/null +++ b/packages/dash-platform-queries/src/lib.rs @@ -0,0 +1,25 @@ +//! Transport-free query core of the Dash Platform SDK. +//! +//! This crate carries the pieces of `dash-sdk` that build queries, encode +//! them onto the wire format, and decode/verify proved responses — without a +//! transport implementation (no `rs-dapi-client` and no tonic native +//! channel/TLS stack). Shared generated types and context-provider utilities +//! remain dependencies. Embedders that bring their own transport can depend +//! on this crate alone; `dash-sdk` re-exports everything here at its historical +//! paths. + +// Same allowance the code carried in rs-sdk, whose crate root allows +// `result_large_err` for the dpp/drive error types threaded through here. +#![allow(clippy::result_large_err)] + +pub mod block_info_from_metadata; +pub mod dashpay; +pub mod documents; +pub mod dpns_usernames; +pub mod error; +pub mod mock; +pub mod query_settings; +pub mod transition; +pub mod types; + +pub use error::Error; diff --git a/packages/dash-platform-queries/src/mock.rs b/packages/dash-platform-queries/src/mock.rs new file mode 100644 index 00000000000..115e8445d38 --- /dev/null +++ b/packages/dash-platform-queries/src/mock.rs @@ -0,0 +1,7 @@ +//! Mocking support. +//! +//! The `dash_platform_macros::Mockable` derive expands to an impl of +//! `crate::mock::Mockable`, so every crate that derives it must expose the +//! trait at this path. The trait itself lives in `dapi-grpc` and is defined +//! even when mocks are disabled — serialization then just returns `None`. +pub use dapi_grpc::mock::Mockable; diff --git a/packages/dash-platform-queries/src/query_settings.rs b/packages/dash-platform-queries/src/query_settings.rs new file mode 100644 index 00000000000..cc9cd1a4509 --- /dev/null +++ b/packages/dash-platform-queries/src/query_settings.rs @@ -0,0 +1,38 @@ +//! Query encoding settings. +//! +//! [`QuerySettings`] is a small, borrow-style bundle handed to the SDK's +//! `Query::query` implementations so they can encode a user-facing query into +//! a wire `TransportRequest` without taking a full `&Sdk` dependency. This +//! keeps the encoder layer free of `Sdk`-shaped transitive deps (transport, +//! mock cache, nonce cache, context provider, …) and lets unit tests +//! construct settings directly without spinning up `Sdk::new_mock()`. +//! +//! The fields are the minimum surface a wire encoder needs today: protocol +//! version (to pick V0 vs V1 wire shapes) and the `prove` flag (proof-mode +//! requests vs unproved queries). + +use dpp::version::PlatformVersion; + +/// Settings passed to the SDK's `Query::query` for encoding a user-facing +/// query into a wire `TransportRequest`. +/// +/// Construct via `Sdk::query_settings` for normal use, or directly in unit +/// tests that want to exercise the encoder without an `Sdk`. +#[derive(Debug, Clone, Copy)] +pub struct QuerySettings<'a> { + /// Platform protocol version, used to pick wire encoding (V0 vs V1, etc). + pub protocol_version: &'a PlatformVersion, + + /// Whether to request and verify cryptographic proofs. + pub prove: bool, +} + +impl QuerySettings<'_> { + /// Cheap derivative with proofs forced off — used by `FetchUnproved`. + pub fn without_proofs(&self) -> Self { + Self { + prove: false, + ..*self + } + } +} diff --git a/packages/dash-platform-queries/src/transition/mod.rs b/packages/dash-platform-queries/src/transition/mod.rs new file mode 100644 index 00000000000..097952677ce --- /dev/null +++ b/packages/dash-platform-queries/src/transition/mod.rs @@ -0,0 +1,3 @@ +//! Transport-free state transition helpers. +pub mod put_document; +pub mod validation; diff --git a/packages/dash-platform-queries/src/transition/put_document.rs b/packages/dash-platform-queries/src/transition/put_document.rs new file mode 100644 index 00000000000..3449bb5e672 --- /dev/null +++ b/packages/dash-platform-queries/src/transition/put_document.rs @@ -0,0 +1,176 @@ +//! Transport-free helpers for document create/replace transitions. +//! +//! `dash-sdk`'s `PutDocument` broadcast path calls these; embedders that +//! assemble their own transitions share the same preparation and +//! entropy/id consistency check. + +use crate::Error; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::DocumentType; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::prelude::Identifier; + +/// Returns a copy of `document` with its properties sanitized for the given +/// document type (e.g. integer arrays coerced back into byte arrays after a +/// WASM boundary crossing), leaving the caller's document untouched. +pub fn prepare_document_for_transition( + document: &Document, + document_type: &DocumentType, +) -> Document { + let mut document = document.clone(); + document_type + .as_ref() + .sanitize_document_properties(document.properties_mut()); + 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 the +/// broadcast path trusts the caller's id verbatim when entropy is supplied, +/// 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. +pub 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::InvalidInput(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, INITIAL_REVISION}; + use dpp::platform_value::{platform_value, Value}; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + fn contract_id() -> Identifier { + Identifier::from([1u8; 32]) + } + + fn owner_id() -> Identifier { + 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::InvalidInput(_))), + "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(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create default data contract config"); + let document_type = DocumentType::try_from_schema( + contract_id(), + 1, + config.version(), + "preorder", + platform_value!({ + "type": "object", + "properties": { + "saltedDomainHash": { + "type": "array", + "byteArray": true, + "minItems": 32_u32, + "maxItems": 32_u32, + "position": 0 + } + }, + "required": ["saltedDomainHash"], + "additionalProperties": false, + }), + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("should create DPNS-like document type"); + let integer_array = Value::Array(vec![Value::U64(7); 32]); + let document = Document::V0(DocumentV0 { + id: Identifier::new([3; 32]), + owner_id: owner_id(), + properties: BTreeMap::from([("saltedDomainHash".to_string(), integer_array.clone())]), + revision: Some(INITIAL_REVISION), + ..Default::default() + }); + + let prepared = prepare_document_for_transition(&document, &document_type); + + assert_eq!( + prepared.properties().get("saltedDomainHash"), + Some(&Value::Bytes32([7; 32])) + ); + assert_eq!( + document.properties().get("saltedDomainHash"), + Some(&integer_array) + ); + } +} diff --git a/packages/dash-platform-queries/src/transition/validation.rs b/packages/dash-platform-queries/src/transition/validation.rs new file mode 100644 index 00000000000..164a98befeb --- /dev/null +++ b/packages/dash-platform-queries/src/transition/validation.rs @@ -0,0 +1,42 @@ +use crate::Error; +use dpp::{ + consensus::{basic::BasicError, ConsensusError}, + state_transition::{StateTransition, StateTransitionStructureValidation}, + version::PlatformVersion, +}; + +/// Checks if an error is an UnsupportedFeatureError +fn is_unsupported_feature_error(error: &ConsensusError) -> bool { + matches!( + error, + ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) + ) +} + +/// Ensures a state transition passes structure validation before broadcasting. +/// +/// Note: UnsupportedFeatureError is allowed to pass through, as it indicates +/// that structure validation is not implemented for that state transition type +/// (e.g., identity-based state transitions). The platform will still perform +/// validation during execution. +pub fn ensure_valid_state_transition_structure( + state_transition: &StateTransition, + platform_version: &PlatformVersion, +) -> Result<(), Error> { + let validation_result = state_transition.validate_structure(platform_version); + if validation_result.is_valid() { + Ok(()) + } else { + // Allow UnsupportedFeatureError to pass through - this means structure + // validation is not implemented for this state transition type + let all_unsupported_feature_errors = validation_result + .errors + .iter() + .all(is_unsupported_feature_error); + if all_unsupported_feature_errors { + Ok(()) + } else { + Err(validation_result.into()) + } + } +} diff --git a/packages/dash-platform-queries/src/types/finalized_epoch.rs b/packages/dash-platform-queries/src/types/finalized_epoch.rs new file mode 100644 index 00000000000..af1c273d320 --- /dev/null +++ b/packages/dash-platform-queries/src/types/finalized_epoch.rs @@ -0,0 +1,37 @@ +//! Finalized epoch related types and helpers +use dpp::block::epoch::EpochIndex; + +/// Query used to fetch multiple finalized epochs from Platform. +#[derive(Clone, Debug)] +pub struct FinalizedEpochQuery { + /// Starting epoch index. + pub start_epoch_index: EpochIndex, + /// Whether to include the start epoch. + pub start_epoch_index_included: bool, + /// Ending epoch index. + pub end_epoch_index: EpochIndex, + /// Whether to include the end epoch. + pub end_epoch_index_included: bool, +} + +impl Default for FinalizedEpochQuery { + fn default() -> Self { + Self { + start_epoch_index: 0, + start_epoch_index_included: true, + end_epoch_index: 0, + end_epoch_index_included: true, + } + } +} + +impl From<(EpochIndex, EpochIndex)> for FinalizedEpochQuery { + fn from((start, end): (EpochIndex, EpochIndex)) -> Self { + Self { + start_epoch_index: start, + start_epoch_index_included: true, + end_epoch_index: end, + end_epoch_index_included: true, + } + } +} diff --git a/packages/dash-platform-queries/src/types/mod.rs b/packages/dash-platform-queries/src/types/mod.rs new file mode 100644 index 00000000000..fa3157966fc --- /dev/null +++ b/packages/dash-platform-queries/src/types/mod.rs @@ -0,0 +1,2 @@ +//! Transport-free query types for various dpp objects. +pub mod finalized_epoch; diff --git a/packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs b/packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs new file mode 100644 index 00000000000..127881cc062 --- /dev/null +++ b/packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs @@ -0,0 +1,293 @@ +//! Round-trip tests for the wire codec of [`DocumentQuery`]: +//! `DocumentQuery` → [`GetDocumentsRequest`] → +//! [`DocumentQuery::try_from_request`] must reproduce the original +//! query exactly, on both the V0 (CBOR clause) and V1 (typed proto +//! clause) wire encodings — this is what lets an embedder verify a +//! proved response against nothing but the request bytes it sent. + +use std::sync::Arc; + +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; +use dapi_grpc::platform::v0::get_documents_request::{GetDocumentsRequestV0, Version}; +use dapi_grpc::platform::v0::GetDocumentsRequest; +use dash_platform_queries::documents::document_query::DocumentQuery; +use dash_platform_queries::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::platform_value::Value; +use dpp::prelude::DataContract; +use dpp::tests::fixtures::get_data_contract_fixture; +use dpp::version::PlatformVersion; +use drive::query::{OrderClause, SelectProjection, WhereClause, WhereOperator}; + +fn test_contract() -> Arc { + let platform_version = PlatformVersion::latest(); + Arc::new( + get_data_contract_fixture(None, 0, platform_version.protocol_version).data_contract_owned(), + ) +} + +/// A protocol version whose `drive_abci.query.document_query` +/// feature-version is `0` — encodes onto the V0 (CBOR-clause) wire. +fn v0_platform_version() -> &'static PlatformVersion { + let version = PlatformVersion::get(1).expect("protocol version 1 exists"); + assert_eq!( + version + .drive_abci + .query + .document_query + .default_current_version, + 0, + "protocol version 1 should encode the V0 documents wire" + ); + version +} + +/// The latest protocol version — encodes onto the V1 (typed proto +/// clause) wire. +fn v1_platform_version() -> &'static PlatformVersion { + let version = PlatformVersion::latest(); + assert_eq!( + version + .drive_abci + .query + .document_query + .default_current_version, + 1, + "latest protocol version should encode the V1 documents wire" + ); + version +} + +fn roundtrip(query: &DocumentQuery, platform_version: &PlatformVersion) -> DocumentQuery { + let contract = Arc::clone(&query.data_contract); + let request = query + .clone() + .try_into_request_for_version(platform_version) + .expect("query should encode onto the wire"); + DocumentQuery::try_from_request(request, contract) + .expect("wire request should decode back into a query") +} + +#[test] +fn v1_roundtrip_documents_query_full_surface() { + let contract = test_contract(); + let mut query = DocumentQuery::new(Arc::clone(&contract), "niceDocument") + .expect("document type exists") + .with_where(WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }) + .with_where(WhereClause { + field: "age".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(21), Value::U64(42)]), + }) + .with_where(WhereClause { + field: "balance".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::I64(-5), + }) + .with_order_by(OrderClause { + field: "firstName".to_string(), + ascending: true, + }) + .with_order_by(OrderClause { + field: "age".to_string(), + ascending: false, + }) + .with_limit(42) + .with_offset(7); + query.start = Some(Start::StartAt(vec![1u8; 32])); + + assert_eq!(roundtrip(&query, v1_platform_version()), query); +} + +#[test] +fn v1_roundtrip_start_after() { + let contract = test_contract(); + let mut query = DocumentQuery::new(contract, "niceDocument").expect("document type exists"); + query.start = Some(Start::StartAfter(vec![2u8; 32])); + + assert_eq!(roundtrip(&query, v1_platform_version()), query); +} + +#[test] +fn v1_roundtrip_grouped_count() { + let contract = test_contract(); + let query = DocumentQuery::new(contract, "niceDocument") + .expect("document type exists") + .with_select(SelectProjection::count_star()) + .with_group_by("age") + .with_where(WhereClause { + field: "age".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::U64(18), + }) + .with_limit(5); + + assert_eq!(roundtrip(&query, v1_platform_version()), query); +} + +#[test] +fn v0_roundtrip_documents_query() { + let contract = test_contract(); + let mut query = DocumentQuery::new(Arc::clone(&contract), "niceDocument") + .expect("document type exists") + .with_where(WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }) + .with_where(WhereClause { + field: "age".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(21), Value::U64(42)]), + }) + .with_where(WhereClause { + field: "balance".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::I64(-5), + }) + .with_order_by(OrderClause { + field: "firstName".to_string(), + ascending: true, + }) + .with_order_by(OrderClause { + field: "age".to_string(), + ascending: false, + }) + .with_limit(10); + query.start = Some(Start::StartAfter(vec![3u8; 32])); + + let request = query + .clone() + .try_into_request_for_version(v0_platform_version()) + .expect("query should encode onto the V0 wire"); + assert!( + matches!(request.version, Some(Version::V0(_))), + "protocol version 1 must produce the V0 wire shape" + ); + let decoded = DocumentQuery::try_from_request(request, contract) + .expect("V0 wire request should decode back into a query"); + assert_eq!(decoded, query); +} + +#[test] +fn v0_rejects_malformed_where_cbor() { + let contract = test_contract(); + let request = GetDocumentsRequest { + version: Some(Version::V0(GetDocumentsRequestV0 { + data_contract_id: contract.id().to_vec(), + document_type: "niceDocument".to_string(), + r#where: vec![0x9F], // truncated CBOR array + order_by: vec![], + limit: 0, + prove: true, + start: None, + })), + }; + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("truncated where CBOR must be rejected"); + assert!( + error.to_string().contains("unable to decode 'where' query"), + "unexpected error: {error}" + ); +} + +#[test] +fn v1_rejects_unknown_where_operator() { + let contract = test_contract(); + let query = DocumentQuery::new(Arc::clone(&contract), "niceDocument") + .expect("document type exists") + .with_where(WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + request_v1.where_clauses[0].operator = 99; + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("unknown operator discriminant must be rejected"); + assert!( + error + .to_string() + .contains("unknown WhereOperator discriminant: 99"), + "unexpected error: {error}" + ); +} + +#[test] +fn v1_rejects_explicit_zero_limit() { + let contract = test_contract(); + let query = + DocumentQuery::new(Arc::clone(&contract), "niceDocument").expect("document type exists"); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + request_v1.limit = Some(0); + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("explicit zero limit must be rejected, mirroring the server"); + assert!( + error.to_string().contains("limit = 0"), + "unexpected error: {error}" + ); +} + +#[test] +fn v1_rejects_multi_projection_select() { + let contract = test_contract(); + let query = + DocumentQuery::new(Arc::clone(&contract), "niceDocument").expect("document type exists"); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + let extra_select = request_v1.selects[0].clone(); + request_v1.selects.push(extra_select); + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("multi-projection SELECT must be rejected"); + assert!( + error.to_string().contains("multi-projection SELECT"), + "unexpected error: {error}" + ); +} + +#[test] +fn rejects_contract_mismatch() { + let contract = test_contract(); + let query = + DocumentQuery::new(Arc::clone(&contract), "niceDocument").expect("document type exists"); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + request_v1.data_contract_id = vec![9u8; 32]; + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("mismatched contract id must be rejected"); + assert!( + matches!(error, Error::Protocol(_)), + "unexpected error: {error}" + ); + assert!( + error.to_string().contains("targets data contract"), + "unexpected error: {error}" + ); +} diff --git a/packages/rs-dapi-client/Cargo.toml b/packages/rs-dapi-client/Cargo.toml index 705f5ccec2f..a52c12400fb 100644 --- a/packages/rs-dapi-client/Cargo.toml +++ b/packages/rs-dapi-client/Cargo.toml @@ -26,6 +26,11 @@ backon = { version = "1.3", default-features = false, features = [ "tokio-sleep", ] } tokio = { version = "1.40", features = ["time"] } +# The native transport (tonic channel + TLS) comes from dapi-grpc's transport +# feature; wasm builds use tonic-web-wasm-client instead and must not pull it. +dapi-grpc = { path = "../dapi-grpc", features = [ + "transport", +], default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { version = "0.3.0", features = ["futures"] } diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index 23069f0158e..668415ae2a6 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -42,6 +42,7 @@ dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ "server", "platform", ] } +dash-platform-queries = { path = "../dash-platform-queries", default-features = false } tracing-subscriber = { version = "0.3.22", default-features = false, features = [ "env-filter", "ansi", diff --git a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs index fa5c4da140b..3f67747e5dd 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs @@ -1,367 +1,77 @@ //! Wire-protobuf → drive type conversions for the v1 document //! query surface. //! -//! Lives next to the v1 handler because rs-drive-abci is the only -//! crate that needs the proto-decode direction (the SDK ships the -//! inverse direction in -//! `rs-sdk/src/platform/documents/document_query.rs`). Keeping the -//! two directions in their respective crates avoids forcing -//! `dapi-grpc` into rs-drive's dependency graph just to host shared -//! conversion code. -//! -//! Conversion contract: -//! - Every fallible case maps to [`QueryError::InvalidArgument`] -//! (malformed wire input, **not** future capability). The v1 -//! handler distinguishes this from -//! [`QuerySyntaxError::Unsupported`] (valid request shape, server -//! capability not yet wired) — see `v1/mod.rs`'s +//! The decode logic itself lives in +//! `dash_platform_queries::documents::proto_conversions`, shared +//! with the client-side `DocumentQuery::try_from_request` so the +//! bytes the server decodes and the bytes the proof verifier decodes +//! cannot drift. This module only maps the shared crate's neutral +//! [`DecodeError`] onto this crate's [`QueryError`] surface: +//! - [`DecodeError::InvalidArgument`] (malformed wire input) → +//! [`QueryError::InvalidArgument`]. The v1 handler distinguishes +//! this from [`QuerySyntaxError::Unsupported`] (valid request +//! shape, server capability not yet wired) — see `v1/mod.rs`'s //! `not_yet_implemented` helper. -//! - 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. +//! - [`DecodeError::Unsupported`] (well-formed shape the decoder +//! deliberately refuses, e.g. `ORDER BY` on aggregate keys) → +//! [`QueryError::Query`]\([`QuerySyntaxError::Unsupported`]\). +//! +//! Both mappings preserve the exact message strings this module +//! produced when it owned the decode logic, so the server's error +//! surface is unchanged. use crate::error::query::QueryError; 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, + get_documents_request_v1::Select as ProtoSelect, 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, -}; - -/// 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 -/// [`QueryError::InvalidArgument`] — not `not_yet_implemented`. -pub(super) fn where_operator_from_proto(op: i32) -> Result { - let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { - QueryError::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(super) 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(|| { - QueryError::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(QueryError::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 -/// [`QueryError::InvalidArgument`] for both operator-discriminant -/// and value-shape failures. -pub(super) fn where_clause_from_proto(clause: ProtoWhereClause) -> Result { - let operator = where_operator_from_proto(clause.operator)?; - let value = clause.value.ok_or_else(|| { - QueryError::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, - }) +use dash_platform_queries::documents::proto_conversions::{self as shared, DecodeError}; +use drive::error::query::QuerySyntaxError; +use drive::query::{HavingClause, OrderClause, SelectProjection, WhereClause}; + +fn map_decode_error(error: DecodeError) -> QueryError { + match error { + DecodeError::InvalidArgument(msg) => QueryError::InvalidArgument(msg), + DecodeError::Unsupported(msg) => QueryError::Query(QuerySyntaxError::Unsupported(msg)), + } } -/// Plural form of [`where_clause_from_proto`] for the request-level -/// `repeated WhereClause` field. Returns an error on the first -/// malformed clause; the v1 handler surfaces this through +/// Decode the request-level `repeated WhereClause` field via the +/// shared decoder. Returns an error on the first malformed clause; +/// the v1 handler surfaces this through /// `QueryValidationResult::new_with_error` so the caller sees the /// rejection on the same response shape as a downstream validation /// failure. pub(super) fn where_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(where_clause_from_proto).collect() + shared::where_clauses_from_proto(clauses).map_err(map_decode_error) } -/// 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 -/// at routing time with `Unsupported("ORDER BY on aggregate …")`). -/// Unset (`None`) is rejected as malformed wire input. -pub(super) 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(QueryError::Query( - drive::error::query::QuerySyntaxError::Unsupported( - "ORDER BY on aggregate keys is not yet implemented".to_string(), - ), - )), - None => Err(QueryError::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. +/// Decode the request-level `repeated OrderClause` field via the +/// shared decoder. Aggregate ordering targets are rejected with +/// `Unsupported("ORDER BY on aggregate keys is not yet implemented")`. pub(super) fn order_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(order_clause_from_proto).collect() -} - -// The `having_*_from_proto` family below decodes clauses the server -// then refuses: `having` evaluation is not implemented, so every -// non-empty HAVING is rejected at routing. Decoding still runs first -// (see `query_documents_v1`) so wire-malformed clauses surface as -// `InvalidArgument` rather than being masked by the capability -// rejection. The inner helpers keep a per-function -// `#[allow(dead_code)]` — rather than module-wide — so any future -// addition outside this family still trips the lint. - -/// 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 -/// [`QueryError::InvalidArgument`]. -#[allow(dead_code)] -fn having_function_from_proto(function: i32) -> Result { - let proto = having_aggregate::Function::try_from(function).map_err(|_| { - QueryError::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`]. -#[allow(dead_code)] -fn having_operator_from_proto(operator: i32) -> Result { - let proto = having_clause::Operator::try_from(operator).map_err(|_| { - QueryError::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, - }) + shared::order_clauses_from_proto(clauses).map_err(map_decode_error) } -/// 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. -#[allow(dead_code)] -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 -/// [`QueryError::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. -#[allow(dead_code)] -pub(super) fn having_clause_from_proto( - clause: ProtoHavingClause, -) -> Result { - let aggregate = clause.aggregate.ok_or_else(|| { - QueryError::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(|| { - QueryError::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. -#[allow(dead_code)] +/// Decode the request-level `repeated HavingClause` field via the +/// shared decoder. Decoding runs before the capability rejection +/// (HAVING evaluation is not implemented) so wire-malformed clauses +/// surface as `InvalidArgument` rather than being masked by the +/// blanket "not yet implemented". pub(super) fn having_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - 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 -/// [`QueryError::InvalidArgument`]. -fn select_function_from_proto(function: i32) -> Result { - let proto = select::Function::try_from(function).map_err(|_| { - QueryError::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, - }) + shared::having_clauses_from_proto(clauses).map_err(map_decode_error) } -/// 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 in `validate_and_route`, not here, so the -/// converter only enforces well-formed proto. +/// Decode a wire `Select` into drive's [`SelectProjection`] via the +/// shared decoder. Per-function field constraints (e.g. `DOCUMENTS` +/// must have empty `field`, `SUM`/`AVG` require non-empty) are +/// checked at routing time in `validate_and_route`, not here. pub(super) fn select_from_proto(select: ProtoSelect) -> Result { - Ok(SelectProjection { - function: select_function_from_proto(select.function)?, - field: select.field, - }) + shared::select_from_proto(select).map_err(map_decode_error) } diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 8f8ba61d9df..651a7b6a11f 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -25,6 +25,7 @@ grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "a dash-async = { path = "../rs-dash-async" } dash-context-provider = { path = "../rs-context-provider", default-features = false } dash-platform-macros = { path = "../rs-dash-platform-macros" } +dash-platform-queries = { path = "../dash-platform-queries" } platform-encryption = { path = "../rs-platform-encryption" } http = { version = "1.1" } ciborium = { version = "0.2.2" } @@ -89,6 +90,7 @@ spv-client = [ mocks = [ "dep:serde", "dep:serde_json", + "dash-platform-queries/mocks", "rs-dapi-client/mocks", "rs-dapi-client/dump", "dpp/document-cbor-conversion", diff --git a/packages/rs-sdk/README.md b/packages/rs-sdk/README.md index 9a33a75b7b2..63c60515dc9 100644 --- a/packages/rs-sdk/README.md +++ b/packages/rs-sdk/README.md @@ -42,6 +42,18 @@ connection to Platform. You can see examples of mocking in [mock_fetch.rs](tests/fetch/mock_fetch.rs) and [mock_fetch_many.rs](tests/fetch/mock_fetch_many.rs). +## Transport-free consumption + +The query-building, wire-encoding, and proof-verification layers of this SDK +live in the [`dash-platform-queries`](../dash-platform-queries) crate, which +this crate depends on and re-exports at the historical paths. Embedders that +bring their own transport and trust context (Dash Core's platform GUI, block +explorers) can depend on `dash-platform-queries` + `drive-proof-verifier` +directly and get typed, proof-verified results without `rs-dapi-client` or +tonic's native channel/TLS stack in their dependency tree. Shared generated +types and context-provider utilities remain dependencies. See that crate's +README for details. + ## Examples You can find quick start example in `examples/` folder. Examples must be configured by setting constants. diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 4e4ab3a9a18..89ade69e741 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -133,6 +133,19 @@ pub enum Error { NoAvailableAddressesToRetry(Box), } +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), + } + } +} + /// State transition broadcast error #[derive(Debug, thiserror::Error)] #[error("state transition broadcast error: {message}")] diff --git a/packages/rs-sdk/src/lib.rs b/packages/rs-sdk/src/lib.rs index cb92f01d8d0..0299351a8dd 100644 --- a/packages/rs-sdk/src/lib.rs +++ b/packages/rs-sdk/src/lib.rs @@ -90,6 +90,7 @@ pub use error::Error; pub use sdk::{RequestSettings, Sdk, SdkBuilder}; pub use dapi_grpc; +pub use dash_platform_queries; pub use dpp; #[cfg(feature = "core_spv")] pub use dpp::dash_spv; diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index 9e2cbdf89af..e42a42b8995 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -6,7 +6,7 @@ // and while it will change the substance, the API structure will remain the same. pub mod address_sync; -pub mod block_info_from_metadata; +pub use dash_platform_queries::block_info_from_metadata; pub mod dashpay; mod delegate; pub mod documents; @@ -18,7 +18,7 @@ mod fetch_unproved; pub mod group_actions; pub mod identities_contract_keys_query; pub mod query; -pub mod query_settings; +pub use dash_platform_queries::query_settings; #[cfg(feature = "shielded")] pub mod shielded; pub mod tokens; diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index d1f59bc9cab..0bcf9cd88a7 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; @@ -205,14 +207,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 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 { - 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 @@ -308,90 +307,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 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); - // 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 + // 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]; 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 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 = 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 ce482872996..e9f69831cb6 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -11,6 +11,9 @@ pub use contact_request::{ 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/delegate.rs b/packages/rs-sdk/src/platform/delegate.rs index f58ecb03652..fddf6f5f62d 100644 --- a/packages/rs-sdk/src/platform/delegate.rs +++ b/packages/rs-sdk/src/platform/delegate.rs @@ -26,6 +26,8 @@ #[macro_export] macro_rules! delegate_transport_request_variant { ($request:ty, $response:ty, $($variant:ident),+) => { + impl $crate::platform::query::WireQuery for $request {} + impl $crate::platform::dapi::transport::TransportRequest for $request { type Client = $crate::platform::dapi::transport::PlatformGrpcClient; diff --git a/packages/rs-sdk/src/platform/documents/document_query_sdk.rs b/packages/rs-sdk/src/platform/documents/document_query_sdk.rs new file mode 100644 index 00000000000..b51b1dd7c2f --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/document_query_sdk.rs @@ -0,0 +1,69 @@ +//! Sdk-bound surface of [`DocumentQuery`]. +//! +//! [`DocumentQuery`] itself is transport-free and lives in +//! `dash-platform-queries`; this module holds the pieces that need an +//! [`Sdk`]: the contract-fetching constructor and the rich→wire +//! [`Query`](crate::platform::Query) encoding step. + +use crate::platform::documents::document_query::DocumentQuery; +use crate::platform::Fetch; +use crate::{error::Error, sdk::Sdk}; +use dapi_grpc::platform::v0 as platform_proto; +use dapi_grpc::platform::v0::GetDocumentsRequest; +use dpp::prelude::{DataContract, Identifier}; +use dpp::version::TryFromPlatformVersioned; + +/// Sdk-bound extension methods for [`DocumentQuery`]. +/// +/// Kept as an extension trait because [`DocumentQuery`] is defined in the +/// transport-free `dash-platform-queries` crate, so its Sdk-dependent +/// constructor cannot be an inherent method there. Bring this trait into +/// scope to keep calling `DocumentQuery::new_with_data_contract_id(...)`. +#[allow(async_fn_in_trait)] +pub trait DocumentQuerySdk: Sized { + /// Create new document query for provided document type name and data contract ID. + /// + /// Note that this method will fetch data contract first. + async fn new_with_data_contract_id( + api: &Sdk, + data_contract_id: Identifier, + document_type_name: &str, + ) -> Result; +} + +impl DocumentQuerySdk for DocumentQuery { + async fn new_with_data_contract_id( + api: &Sdk, + data_contract_id: Identifier, + document_type_name: &str, + ) -> Result { + let data_contract = + DataContract::fetch(api, data_contract_id) + .await? + .ok_or(Error::MissingDependency( + "DataContract".to_string(), + format!("data contract {} not found", data_contract_id), + ))?; + + Self::new(data_contract, document_type_name).map_err(Error::from) + } +} + +/// Encode a [`DocumentQuery`] onto the wire using the SDK's +/// currently-known [`dpp::version::PlatformVersion`] for V0 vs V1 dispatch. +/// +/// The [`Fetch`] / [`FetchMany`](crate::platform::FetchMany) trampolines for +/// [`dpp::document::Document`] (and the document aggregate views) split +/// `Fetch::Query = DocumentQuery` (rich, what `FromProof` binds to) from +/// `Fetch::Request = GetDocumentsRequest` (wire); this impl is the +/// rich→wire step the trampoline invokes via +/// `Query::query(&rich, &sdk.query_settings())`. +impl crate::platform::Query for DocumentQuery { + fn query( + &self, + settings: &crate::platform::QuerySettings<'_>, + ) -> Result { + GetDocumentsRequest::try_from_platform_versioned(self.clone(), settings.protocol_version) + .map_err(Error::from) + } +} diff --git a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs new file mode 100644 index 00000000000..b0ede2306dc --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs @@ -0,0 +1,48 @@ +//! [`Fetch`] bindings for the document aggregate views. +//! +//! The `FromProof` decoding for these types moved to the transport-free +//! `dash-platform-queries` crate together with [`DocumentQuery`]; the +//! [`Fetch`] trait is Sdk-bound, so its impls stay here. + +use crate::platform::documents::document_query::DocumentQuery; +use crate::platform::Fetch; +use dapi_grpc::platform::v0::GetDocumentsRequest; +use drive_proof_verifier::{ + DocumentAverage, DocumentCount, DocumentRankedEntries, DocumentSplitAverages, + DocumentSplitCounts, DocumentSplitSums, DocumentSum, +}; + +impl Fetch for DocumentCount { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} + +impl Fetch for DocumentSum { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} + +impl Fetch for DocumentAverage { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} + +impl Fetch for DocumentSplitCounts { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} + +impl Fetch for DocumentSplitSums { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} + +impl Fetch for DocumentSplitAverages { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} + +impl Fetch for DocumentRankedEntries { + type Query = DocumentQuery; + type Request = GetDocumentsRequest; +} diff --git a/packages/rs-sdk/src/platform/documents/mod.rs b/packages/rs-sdk/src/platform/documents/mod.rs index dbb6c5ae5ba..dd97c903b4d 100644 --- a/packages/rs-sdk/src/platform/documents/mod.rs +++ b/packages/rs-sdk/src/platform/documents/mod.rs @@ -1,27 +1,18 @@ -pub(super) mod average_proof_helpers; -pub(super) mod count_proof_helpers; -/// `Fetch` impl for the average-side aggregate result. Returns -/// `(count, sum)`; client divides. -pub mod document_average; -pub mod document_count; -pub mod document_history_query; -pub mod document_query; -/// `Fetch` impl for the ranked (`GROUP BY … ORDER BY LIMIT n -/// [OFFSET m]`) result — one entry per returned group, in ranking order, -/// plus the rank the page starts at. Requires an index declaring -/// `rankedCountable` / `rankedSummable` / `rankedAverageable` -/// (protocol version 14+). -pub mod document_ranked_entries; -/// `Fetch` impl for the average-side per-entry result. Mirrors -/// `document_split_sums`. -pub mod document_split_averages; -pub mod document_split_counts; -/// `Fetch` impl for the sum-side per-entry result. Mirrors -/// `document_split_counts`. -pub mod document_split_sums; -/// `Fetch` impl for the sum-side aggregate result. Mirrors -/// `document_count`. Lights up alongside grovedb PR 670. -pub mod document_sum; -pub(super) mod ranked_proof_helpers; -pub(super) mod sum_proof_helpers; +//! Document query surface. +//! +//! The transport-free core (query types, wire encoding, proof decoding) +//! lives in the `dash-platform-queries` crate and is re-exported here at +//! its historical paths; this module keeps the Sdk-bound pieces — `Fetch` +//! bindings, the contract-fetching constructor, and transition builders. + +pub use dash_platform_queries::documents::{ + document_average, document_count, document_history_query, document_query, + document_ranked_entries, document_split_averages, document_split_counts, document_split_sums, + document_sum, +}; + +pub mod document_query_sdk; +mod fetch_bindings; pub mod transitions; + +pub use document_query_sdk::DocumentQuerySdk; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 4d6ba1f660f..15021d9042d 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -2,6 +2,10 @@ mod contested_queries; mod queries; pub use contested_queries::ContestedDpnsUsername; +pub use dash_platform_queries::dpns_usernames::{ + build_dpns_preorder_and_domain_documents, convert_to_homograph_safe_chars, + is_contested_username, is_valid_username, +}; pub use queries::DpnsUsername; use crate::platform::transition::put_document::PutDocument; @@ -11,30 +15,14 @@ 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; -/// 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() -} - fn extract_dpns_label(name: &str) -> &str { if let Some(dot_pos) = name.rfind('.') { let (label_part, suffix) = name.split_at(dot_pos); @@ -56,93 +44,6 @@ fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } -/// 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) -/// -/// Pattern: `^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$` -/// -/// # Arguments -/// -/// * `label` - The username label to check (e.g., "alice") -/// -/// # Returns -/// -/// 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 -} - -/// Check if a username is contested (requires masternode voting) -/// -/// A username is contested if its normalized label: -/// - Is between 3 and 19 characters long (inclusive) -/// - Contains only lowercase letters a-z, digits 0-1, and hyphens -/// -/// # Arguments -/// -/// * `label` - The username label to check (e.g., "alice") -/// -/// # Returns -/// -/// Returns `true` if the username would be contested, `false` otherwise -pub fn is_contested_username(label: &str) -> bool { - let normalized = convert_to_homograph_safe_chars(label); - - // Check length - if normalized.len() < 3 || normalized.len() > 19 { - return false; - } - - // Check if all characters match the pattern [a-z01-] - normalized - .chars() - .all(|c| matches!(c, 'a'..='z' | '0' | '1' | '-')) -} - -/// 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; @@ -254,95 +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 + // 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(), + &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 @@ -521,14 +344,6 @@ impl Sdk { mod tests { use super::*; - #[test] - fn test_convert_to_homograph_safe_chars() { - assert_eq!(convert_to_homograph_safe_chars("alice"), "a11ce"); - assert_eq!(convert_to_homograph_safe_chars("bob"), "b0b"); - assert_eq!(convert_to_homograph_safe_chars("COOL"), "c001"); - assert_eq!(convert_to_homograph_safe_chars("test123"), "test123"); - } - #[test] fn test_normalize_dpns_label_strips_dash_suffix_case_insensitively() { // Bare label and full name normalize to the same value, regardless @@ -562,89 +377,4 @@ mod tests { assert_eq!(extract_dpns_label("alice.eth"), "alice.eth"); assert_eq!(extract_dpns_label(".dash"), ""); } - - #[test] - fn test_is_valid_username() { - // Valid usernames - assert!(is_valid_username("abc")); - assert!(is_valid_username("alice")); - assert!(is_valid_username("Alice123")); - assert!(is_valid_username("dash-p2p")); - assert!(is_valid_username("test-name-123")); - assert!(is_valid_username("a-b-c")); - assert!(is_valid_username("user2024")); - assert!(is_valid_username("CryptoKing")); - assert!(is_valid_username("web3-developer")); - assert!(is_valid_username("a".repeat(63).as_str())); // Max length - - // Invalid - too short - assert!(!is_valid_username("ab")); - assert!(!is_valid_username("a")); - assert!(!is_valid_username("")); - - // Invalid - too long - assert!(!is_valid_username("a".repeat(64).as_str())); - - // Invalid - starts with hyphen - assert!(!is_valid_username("-alice")); - assert!(!is_valid_username("-test")); - - // Invalid - ends with hyphen - assert!(!is_valid_username("alice-")); - assert!(!is_valid_username("test-")); - - // Invalid - starts and ends with hyphen - assert!(!is_valid_username("-alice-")); - - // Invalid - contains invalid characters - assert!(!is_valid_username("alice_bob")); // underscore - assert!(!is_valid_username("alice.bob")); // dot - assert!(!is_valid_username("alice@dash")); // at sign - assert!(!is_valid_username("alice!")); // exclamation - assert!(!is_valid_username("alice bob")); // space - assert!(!is_valid_username("alice#1")); // hash - assert!(!is_valid_username("alice$")); // dollar - assert!(!is_valid_username("alice%20")); // percent - - // Invalid - consecutive hyphens - assert!(!is_valid_username("alice--bob")); - assert!(!is_valid_username("test---name")); - } - - #[test] - fn test_is_contested_username() { - // Contested usernames (3-19 chars, only [a-z01-]) - assert!(is_contested_username("abc")); - assert!(is_contested_username("alice")); // becomes "a11ce" - assert!(is_contested_username("b0b")); - assert!(is_contested_username("cool")); // becomes "c001" - assert!(is_contested_username("a-b-c")); - assert!(is_contested_username("hello")); // becomes "he110" - assert!(is_contested_username("world")); // becomes "w0r1d" - assert!(is_contested_username("dash")); - assert!(is_contested_username("a11ce")); // already normalized - assert!(is_contested_username("dash-dao")); // becomes "dash-da0" - - // Not contested - too short - assert!(!is_contested_username("ab")); - assert!(!is_contested_username("io")); // becomes "10" which is 2 chars - assert!(!is_contested_username("a")); - - // Not contested - too long (20+ chars) - assert!(!is_contested_username("twenty-characters-ab")); // 20 chars - assert!(!is_contested_username( - "this-is-a-very-long-username-that-exceeds-limit" - )); - - // Not contested - contains invalid characters after normalization - assert!(!is_contested_username("alice2")); // contains '2' - assert!(!is_contested_username("alice_bob")); // contains '_' - assert!(!is_contested_username("alice.bob")); // contains '.' - assert!(!is_contested_username("alice@dash")); // contains '@' - assert!(!is_contested_username("alice!")); // contains '!' - assert!(!is_contested_username("test123")); // contains '2' and '3' - assert!(!is_contested_username("dash-p2p")); // contains 'p' and '2' - assert!(!is_contested_username("user5")); // contains '5' - assert!(!is_contested_username("name_with_underscore")); // contains '_' - } } diff --git a/packages/rs-sdk/src/platform/identities_contract_keys_query.rs b/packages/rs-sdk/src/platform/identities_contract_keys_query.rs index 02ede03136f..e939e5b2d82 100644 --- a/packages/rs-sdk/src/platform/identities_contract_keys_query.rs +++ b/packages/rs-sdk/src/platform/identities_contract_keys_query.rs @@ -88,6 +88,8 @@ impl Query for IdentitiesContractKeysQuery { } } +impl crate::platform::query::WireQuery for IdentitiesContractKeysQuery {} + impl TransportRequest for IdentitiesContractKeysQuery { type Client = ::Client; type Response = ::Response; diff --git a/packages/rs-sdk/src/platform/query.rs b/packages/rs-sdk/src/platform/query.rs index fb52158c03d..b19e3b59837 100644 --- a/packages/rs-sdk/src/platform/query.rs +++ b/packages/rs-sdk/src/platform/query.rs @@ -99,8 +99,8 @@ pub trait Query: Send + Debug + Clone { /// /// * `settings` - A [`QuerySettings`](crate::platform::QuerySettings) borrowing the encoder /// inputs from the SDK: protocol version (used by encoders that pick wire shapes - /// per version — today only [`DocumentQuery`]'s V0/V1 split), `prove` flag, - /// and request settings. Construct from an SDK via + /// per version — today only [`DocumentQuery`]'s V0/V1 split) and the `prove` flag. + /// Construct from an SDK via /// [`Sdk::query_settings`](crate::Sdk::query_settings), or directly in unit tests /// that want to exercise the encoder without spinning up an `Sdk`. /// @@ -110,9 +110,97 @@ pub trait Query: Send + Debug + Clone { fn query(&self, settings: &crate::platform::QuerySettings<'_>) -> Result; } +/// Marker for wire proto request types that serve as their own [`Query`] +/// through the blanket identity impl below. +/// +/// This local marker exists for trait coherence: [`DocumentQuery`] moved to +/// the transport-free `dash-platform-queries` crate, so it is now foreign to +/// this crate. A blanket bounded only by the (equally foreign) +/// [`TransportRequest`] trait would conflict with the explicit +/// `impl Query for DocumentQuery` — rustc must assume some +/// future upstream crate could implement `TransportRequest` for +/// `DocumentQuery`. Because `WireQuery` is local and only ever implemented +/// explicitly (never via a blanket), the compiler can prove the two impl +/// sets disjoint. +/// +/// When adding a new endpoint whose request proto is used directly as its +/// own query (`Fetch::Query = Fetch::Request`), add the proto to the +/// `impl_wire_query!` list below; a missing entry fails to compile at the +/// fetch call site with a `WireQuery is not satisfied` error. +pub trait WireQuery {} + +macro_rules! impl_wire_query { + ($($request:ty),+ $(,)?) => { + $(impl WireQuery for $request {})+ + }; +} + +impl_wire_query!( + proto::BroadcastStateTransitionRequest, + proto::GetAddressInfoRequest, + proto::GetAddressesBranchStateRequest, + proto::GetAddressesInfosRequest, + proto::GetAddressesTrunkStateRequest, + proto::GetConsensusParamsRequest, + proto::GetContestedResourceIdentityVotesRequest, + proto::GetContestedResourceVoteStateRequest, + proto::GetContestedResourceVotersForIdentityRequest, + proto::GetContestedResourcesRequest, + proto::GetCurrentQuorumsInfoRequest, + proto::GetDataContractHistoryRequest, + proto::GetDataContractRequest, + proto::GetDataContractsRequest, + proto::GetDocumentHistoryRequest, + proto::GetDocumentsRequest, + proto::GetEpochsInfoRequest, + proto::GetEvonodesProposedEpochBlocksByIdsRequest, + proto::GetEvonodesProposedEpochBlocksByRangeRequest, + proto::GetFinalizedEpochInfosRequest, + proto::GetGroupActionSignersRequest, + proto::GetGroupActionsRequest, + proto::GetGroupInfoRequest, + proto::GetGroupInfosRequest, + proto::GetIdentitiesBalancesRequest, + proto::GetIdentitiesContractKeysRequest, + proto::GetIdentitiesTokenBalancesRequest, + proto::GetIdentitiesTokenInfosRequest, + proto::GetIdentityBalanceAndRevisionRequest, + proto::GetIdentityBalanceRequest, + proto::GetIdentityByNonUniquePublicKeyHashRequest, + proto::GetIdentityByPublicKeyHashRequest, + proto::GetIdentityContractNonceRequest, + proto::GetIdentityKeysRequest, + proto::GetIdentityNonceRequest, + proto::GetIdentityRequest, + proto::GetIdentityTokenBalancesRequest, + proto::GetIdentityTokenInfosRequest, + proto::GetMostRecentShieldedAnchorRequest, + proto::GetPathElementsRequest, + proto::GetPrefundedSpecializedBalanceRequest, + proto::GetProtocolVersionUpgradeStateRequest, + proto::GetProtocolVersionUpgradeVoteStatusRequest, + proto::GetRecentAddressBalanceChangesRequest, + proto::GetRecentCompactedAddressBalanceChangesRequest, + proto::GetShieldedAnchorsRequest, + proto::GetShieldedEncryptedNotesRequest, + proto::GetShieldedNotesCountRequest, + proto::GetShieldedNullifiersRequest, + proto::GetShieldedPoolStateRequest, + proto::GetStatusRequest, + proto::GetTokenContractInfoRequest, + proto::GetTokenDirectPurchasePricesRequest, + proto::GetTokenPerpetualDistributionLastClaimRequest, + proto::GetTokenPreProgrammedDistributionsRequest, + proto::GetTokenStatusesRequest, + proto::GetTokenTotalSupplyRequest, + proto::GetTotalCreditsInPlatformRequest, + proto::GetVotePollsByEndDateRequest, + proto::WaitForStateTransitionResultRequest, +); + impl Query for T where - T: TransportRequest + Sized + Send + Sync + Clone + Debug, + T: TransportRequest + WireQuery + Sized + Send + Sync + Clone + Debug, T::Response: Send + Sync + Debug, { fn query(&self, settings: &crate::platform::QuerySettings<'_>) -> Result { diff --git a/packages/rs-sdk/src/platform/query_settings.rs b/packages/rs-sdk/src/platform/query_settings.rs deleted file mode 100644 index f5741dbe04a..00000000000 --- a/packages/rs-sdk/src/platform/query_settings.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Query encoding settings. -//! -//! [`QuerySettings`] is a small, borrow-style bundle handed to -//! [`crate::platform::query::Query::query`] implementations so they can encode -//! a user-facing query into a wire `TransportRequest` without taking a full -//! `&Sdk` dependency. This keeps the encoder layer free of `Sdk`-shaped -//! transitive deps (transport, mock cache, nonce cache, context provider, …) -//! and lets unit tests construct settings directly without spinning up -//! `Sdk::new_mock()`. -//! -//! The fields are the minimum surface a wire encoder needs today: -//! protocol version (to pick V0 vs V1 wire shapes), the `prove` flag -//! (proof-mode requests vs unproved queries), and a borrowed -//! [`RequestSettings`] for any future encoder that needs to consult -//! transport-layer hints (timeouts, ban policy, …) — none do today, but -//! it costs nothing to thread through and avoids another trait churn -//! when the first encoder needs it. - -use dpp::version::PlatformVersion; -use rs_dapi_client::RequestSettings; - -/// Settings passed to [`crate::platform::query::Query::query`] for encoding a -/// user-facing query into a wire `TransportRequest`. -/// -/// Construct via [`crate::Sdk::query_settings`] for normal use, or directly in -/// unit tests that want to exercise the encoder without an `Sdk`. -#[derive(Debug, Clone, Copy)] -pub struct QuerySettings<'a> { - /// Transport-layer settings (timeouts, retries, TLS, ban behaviour). - /// Not consulted by any current encoder; threaded for forward compatibility. - pub request_settings: &'a RequestSettings, - - /// Platform protocol version, used to pick wire encoding (V0 vs V1, etc). - pub protocol_version: &'a PlatformVersion, - - /// Whether to request and verify cryptographic proofs. - pub prove: bool, -} - -impl<'a> QuerySettings<'a> { - /// Cheap derivative with proofs forced off — used by `FetchUnproved`. - pub fn without_proofs(&self) -> Self { - Self { - prove: false, - ..*self - } - } -} diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index fa85a30a0dc..75503ba1428 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -3,15 +3,18 @@ use super::validation::ensure_valid_state_transition_structure; use super::waitable::Waitable; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; +// Transport-free helpers shared with embedders; the implementations moved to +// `dash-platform-queries`. +pub use dash_platform_queries::transition::put_document::{ + ensure_entropy_matches_document_id, prepare_document_for_transition, +}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; 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; @@ -162,162 +165,3 @@ impl> PutDocument for Document { Self::wait_for_response(sdk, state_transition, settings).await } } - -fn prepare_document_for_transition(document: &Document, document_type: &DocumentType) -> Document { - let mut document = document.clone(); - document_type - .as_ref() - .sanitize_document_properties(document.properties_mut()); - 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::version::PlatformVersion; - use std::collections::BTreeMap; - - fn contract_id() -> Identifier { - Identifier::from([1u8; 32]) - } - - fn owner_id() -> Identifier { - 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(); - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create default data contract config"); - let document_type = DocumentType::try_from_schema( - contract_id(), - 1, - config.version(), - "preorder", - platform_value!({ - "type": "object", - "properties": { - "saltedDomainHash": { - "type": "array", - "byteArray": true, - "minItems": 32_u32, - "maxItems": 32_u32, - "position": 0 - } - }, - "required": ["saltedDomainHash"], - "additionalProperties": false, - }), - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("should create DPNS-like document type"); - let integer_array = Value::Array(vec![Value::U64(7); 32]); - let document = Document::V0(DocumentV0 { - id: Identifier::new([3; 32]), - owner_id: owner_id(), - properties: BTreeMap::from([("saltedDomainHash".to_string(), integer_array.clone())]), - revision: Some(INITIAL_REVISION), - ..Default::default() - }); - - let prepared = prepare_document_for_transition(&document, &document_type); - - assert_eq!( - prepared.properties().get("saltedDomainHash"), - Some(&Value::Bytes32([7; 32])) - ); - assert_eq!( - document.properties().get("saltedDomainHash"), - Some(&integer_array) - ); - } -} diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index 846d9ddae2d..d095afb0bb5 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -1,42 +1,7 @@ -use crate::Error; -use dpp::{ - consensus::{basic::BasicError, ConsensusError}, - state_transition::{StateTransition, StateTransitionStructureValidation}, - version::PlatformVersion, -}; - -/// Checks if an error is an UnsupportedFeatureError -fn is_unsupported_feature_error(error: &ConsensusError) -> bool { - matches!( - error, - ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) - ) -} - -/// Ensures a state transition passes structure validation before broadcasting. -/// -/// Note: UnsupportedFeatureError is allowed to pass through, as it indicates -/// that structure validation is not implemented for that state transition type -/// (e.g., identity-based state transitions). The platform will still perform -/// validation during execution. -pub(crate) fn ensure_valid_state_transition_structure( - state_transition: &StateTransition, - platform_version: &PlatformVersion, -) -> Result<(), Error> { - let validation_result = state_transition.validate_structure(platform_version); - if validation_result.is_valid() { - Ok(()) - } else { - // Allow UnsupportedFeatureError to pass through - this means structure - // validation is not implemented for this state transition type - let all_unsupported_feature_errors = validation_result - .errors - .iter() - .all(is_unsupported_feature_error); - if all_unsupported_feature_errors { - Ok(()) - } else { - Err(validation_result.into()) - } - } -} +//! Re-export of the transport-free structure validation helper. +//! +//! The implementation moved to `dash-platform-queries`; broadcast paths in +//! this crate keep importing it from here. It returns the query core's +//! error type, which converts into [`crate::Error`] via `From` at the `?` +//! call sites. +pub(crate) use dash_platform_queries::transition::validation::ensure_valid_state_transition_structure; diff --git a/packages/rs-sdk/src/platform/types/epoch.rs b/packages/rs-sdk/src/platform/types/epoch.rs index 4cbbcf8fe68..b73c271d9c4 100644 --- a/packages/rs-sdk/src/platform/types/epoch.rs +++ b/packages/rs-sdk/src/platform/types/epoch.rs @@ -249,11 +249,9 @@ mod tests { use super::*; use dapi_grpc::platform::v0::get_epochs_info_request; use dpp::block::epoch::EPOCH_KEY_OFFSET; - use rs_dapi_client::RequestSettings; - fn query_settings(request_settings: &RequestSettings) -> crate::platform::QuerySettings<'_> { + fn query_settings() -> crate::platform::QuerySettings<'static> { crate::platform::QuerySettings { - request_settings, protocol_version: dpp::version::PlatformVersion::latest(), prove: true, } @@ -270,8 +268,7 @@ mod tests { /// returning elements. #[test] fn should_build_current_epoch_queries_verifiable_without_metadata() { - let request_settings = RequestSettings::default(); - let settings = query_settings(&request_settings); + let settings = query_settings(); // Step 1: the probe is ascending with an explicit genesis start. let probe = current_epoch_probe_query() diff --git a/packages/rs-sdk/src/platform/types/evonode.rs b/packages/rs-sdk/src/platform/types/evonode.rs index 1ccc5553f4f..4bba5ca8e10 100644 --- a/packages/rs-sdk/src/platform/types/evonode.rs +++ b/packages/rs-sdk/src/platform/types/evonode.rs @@ -51,6 +51,8 @@ impl Mockable for EvoNode { serde_json::ser::to_vec(self).ok() } } +impl crate::platform::query::WireQuery for EvoNode {} + impl TransportRequest for EvoNode { type Client = PlatformGrpcClient; type Response = proto::GetStatusResponse; diff --git a/packages/rs-sdk/src/platform/types/finalized_epoch.rs b/packages/rs-sdk/src/platform/types/finalized_epoch.rs index e9e25f8071d..134943ec707 100644 --- a/packages/rs-sdk/src/platform/types/finalized_epoch.rs +++ b/packages/rs-sdk/src/platform/types/finalized_epoch.rs @@ -4,40 +4,7 @@ use crate::Error; use dapi_grpc::platform::v0::{get_finalized_epoch_infos_request, GetFinalizedEpochInfosRequest}; use dpp::block::epoch::EpochIndex; -/// Query used to fetch multiple finalized epochs from Platform. -#[derive(Clone, Debug)] -pub struct FinalizedEpochQuery { - /// Starting epoch index. - pub start_epoch_index: EpochIndex, - /// Whether to include the start epoch. - pub start_epoch_index_included: bool, - /// Ending epoch index. - pub end_epoch_index: EpochIndex, - /// Whether to include the end epoch. - pub end_epoch_index_included: bool, -} - -impl Default for FinalizedEpochQuery { - fn default() -> Self { - Self { - start_epoch_index: 0, - start_epoch_index_included: true, - end_epoch_index: 0, - end_epoch_index_included: true, - } - } -} - -impl From<(EpochIndex, EpochIndex)> for FinalizedEpochQuery { - fn from((start, end): (EpochIndex, EpochIndex)) -> Self { - Self { - start_epoch_index: start, - start_epoch_index_included: true, - end_epoch_index: end, - end_epoch_index_included: true, - } - } -} +pub use dash_platform_queries::types::finalized_epoch::FinalizedEpochQuery; impl Query for FinalizedEpochQuery { fn query( diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index a9f76afbf5d..87be5dd9c12 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -581,15 +581,14 @@ impl Sdk { self.proofs } - /// Build a [`QuerySettings`] borrowing this SDK's protocol version, - /// request settings, and `prove` flag. + /// Build a [`QuerySettings`] borrowing this SDK's protocol version + /// and `prove` flag. /// /// Hand the resulting context to [`crate::platform::Query::query`] when /// you need to encode a user-facing query into a wire `TransportRequest` /// without taking a full `&Sdk` dependency through the encoder layer. pub fn query_settings(&self) -> crate::platform::QuerySettings<'_> { crate::platform::QuerySettings { - request_settings: &self.dapi_client_settings, protocol_version: self.version(), prove: self.prove(), } diff --git a/packages/rs-sdk/tests/fetch/common.rs b/packages/rs-sdk/tests/fetch/common.rs index b9ff7a69174..babb610e141 100644 --- a/packages/rs-sdk/tests/fetch/common.rs +++ b/packages/rs-sdk/tests/fetch/common.rs @@ -197,9 +197,7 @@ pub(crate) async fn setup_sdk_for_test_case (String, Sdk) { - let request_settings = rs_dapi_client::RequestSettings::default(); let settings = QuerySettings { - request_settings: &request_settings, protocol_version: dpp::version::PlatformVersion::latest(), prove: true, }; diff --git a/packages/rs-sdk/tests/fetch/document.rs b/packages/rs-sdk/tests/fetch/document.rs index df3b3da576f..c786bcd2577 100644 --- a/packages/rs-sdk/tests/fetch/document.rs +++ b/packages/rs-sdk/tests/fetch/document.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::{common::setup_logs, config::Config}; -use dash_sdk::platform::{DocumentQuery, Fetch, FetchMany}; +use dash_sdk::platform::{documents::DocumentQuerySdk, DocumentQuery, Fetch, FetchMany}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::string_encoding::Encoding; diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index 65fdb6994c0..e576b5463c9 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -140,9 +140,10 @@ fn v0_wire_shape_with_forced_v0_platform_version() { #[test] fn v0_rejects_count_star_projection() { let q = build_basic_document_query().with_select(SelectProjection::count_star()); - let err = q - .try_into_request_for_version(v0_dispatch_version()) - .expect_err("count_star on v0 must reject"); + let err = SdkError::from( + q.try_into_request_for_version(v0_dispatch_version()) + .expect_err("count_star on v0 must reject"), + ); match err { SdkError::Config(msg) => assert!( msg.contains("v3.1+"), @@ -155,9 +156,10 @@ fn v0_rejects_count_star_projection() { #[test] fn v0_rejects_group_by() { let q = build_basic_document_query().with_group_by("a"); - let err = q - .try_into_request_for_version(v0_dispatch_version()) - .expect_err("group_by on v0 must reject"); + let err = SdkError::from( + q.try_into_request_for_version(v0_dispatch_version()) + .expect_err("group_by on v0 must reject"), + ); assert!(matches!(err, SdkError::Config(_))); } @@ -174,25 +176,23 @@ fn v0_rejects_having() { operator: HavingOperator::GreaterThan, right: HavingRightOperand::Value(Value::U64(0)), }]); - let err = q - .try_into_request_for_version(v0_dispatch_version()) - .expect_err("having on v0 must reject"); + let err = SdkError::from( + q.try_into_request_for_version(v0_dispatch_version()) + .expect_err("having on v0 must reject"), + ); assert!(matches!(err, SdkError::Config(_))); } #[test] fn encoder_dispatches_v0_via_query_settings_without_sdk() { use dash_sdk::platform::{Query, QuerySettings}; - use rs_dapi_client::RequestSettings; // The whole point of QuerySettings: encoder is testable without // `Sdk::new_mock()`. Construct the context directly from a // PlatformVersion whose document_query is pinned to V0 dispatch // and assert the wire shape comes out V0. let v0_pv = v0_dispatch_version(); - let request_settings = RequestSettings::default(); let settings = QuerySettings { - request_settings: &request_settings, protocol_version: v0_pv, prove: true, }; @@ -206,7 +206,6 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { // Same query, latest PlatformVersion (V1 dispatch) — should now // emit V1 wire bytes through the same code path. let latest_settings = QuerySettings { - request_settings: &request_settings, protocol_version: PlatformVersion::latest(), prove: true, }; @@ -261,12 +260,9 @@ fn protocol_version_for_v3_1_dev_keeps_document_query_v1() { #[test] fn document_query_dispatches_v0_when_sdk_initial_version_is_v3_0_pv() { use dash_sdk::platform::{Query, QuerySettings}; - use rs_dapi_client::RequestSettings; let pv_v3_0 = PlatformVersion::get(11).expect("PROTOCOL_VERSION_11 exists"); - let request_settings = RequestSettings::default(); let settings = QuerySettings { - request_settings: &request_settings, protocol_version: pv_v3_0, prove: true, }; diff --git a/packages/rs-sdk/tests/fetch/mock_fetch.rs b/packages/rs-sdk/tests/fetch/mock_fetch.rs index a8c98b4d575..f7f15427064 100644 --- a/packages/rs-sdk/tests/fetch/mock_fetch.rs +++ b/packages/rs-sdk/tests/fetch/mock_fetch.rs @@ -2,7 +2,7 @@ use super::common::{bootstrap_mock_sdk_to_latest, mock_data_contract, mock_document_type}; use dash_sdk::{ - platform::{DocumentQuery, Fetch}, + platform::{documents::DocumentQuerySdk, DocumentQuery, Fetch}, Sdk, SdkBuilder, }; use dpp::{ diff --git a/packages/rs-sdk/tests/fetch/tokens/token_contract_info.rs b/packages/rs-sdk/tests/fetch/tokens/token_contract_info.rs index 21afd47ee49..b1d96ab041f 100644 --- a/packages/rs-sdk/tests/fetch/tokens/token_contract_info.rs +++ b/packages/rs-sdk/tests/fetch/tokens/token_contract_info.rs @@ -4,7 +4,6 @@ use dash_sdk::platform::{Fetch, Identifier, Query, QuerySettings}; use dash_sdk::Sdk; use dpp::tokens::contract_info::TokenContractInfo; use dpp::version::PlatformVersion; -use rs_dapi_client::RequestSettings; #[tokio::test] async fn test_token_contract_info_fetch_by_identifier() { @@ -51,9 +50,7 @@ async fn test_token_contract_info_query_prove_true() { let token_id = Identifier::from_bytes(&[3u8; 32]).unwrap(); let query = TokenContractInfoQuery { token_id }; - let request_settings = RequestSettings::default(); let settings = QuerySettings { - request_settings: &request_settings, protocol_version: PlatformVersion::latest(), prove: true, }; @@ -72,9 +69,7 @@ async fn test_token_contract_info_query_prove_false() { let token_id = Identifier::from_bytes(&[4u8; 32]).unwrap(); let query = TokenContractInfoQuery { token_id }; - let request_settings = RequestSettings::default(); let settings = QuerySettings { - request_settings: &request_settings, protocol_version: PlatformVersion::latest(), prove: false, }; diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index e37159fa6c8..bebc02a33d2 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -121,6 +121,15 @@ impl WasmSdkError { } } +impl From for WasmSdkError { + fn from(err: dash_sdk::dash_platform_queries::Error) -> Self { + // Route through the SDK's own conversion so the transport-free query + // core's errors keep the exact mapping they had when they were + // `SdkError` variants. + SdkError::from(err).into() + } +} + impl From for WasmSdkError { fn from(err: SdkError) -> Self { use SdkError::*;