From 291ded9a5d72f0c4ff450d70bb70f18303b28960 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Feb 2026 19:03:56 -0600 Subject: [PATCH 01/24] feat(wasm-sdk): add prepare_* APIs for idempotent document state transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prepare variants for document create, replace, and delete operations that build and sign a StateTransition without broadcasting. This enables idempotent retry patterns where callers can cache the signed ST bytes and rebroadcast on timeout instead of creating duplicates with new nonces. New methods: - prepareDocumentCreate() — build, sign, return ST - prepareDocumentReplace() — build, sign, return ST - prepareDocumentDelete() — build, sign, return ST These pair with the existing broadcastStateTransition() and waitForResponse() methods already exposed in broadcast.rs. Closes #3090 --- .../src/state_transitions/document.rs | 424 +++++++++++++++++- 1 file changed, 423 insertions(+), 1 deletion(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index be6dc603a00..2f0a21ba60c 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -1,16 +1,34 @@ //! Document state transition implementations for the WASM SDK. //! //! This module provides WASM bindings for document operations like create, replace, delete, etc. +//! +//! # Two-Phase API (Prepare + Execute) +//! +//! In addition to the all-in-one methods (`documentCreate`, `documentReplace`, `documentDelete`), +//! this module provides `prepare_*` variants that build and sign a `StateTransition` without +//! broadcasting it. This enables idempotent retry patterns: +//! +//! 1. Call `prepareDocumentCreate()` to get a signed `StateTransition` +//! 2. Cache `stateTransition.toBytes()` for retry safety +//! 3. Call `broadcastStateTransition(st)` + `waitForResponse(st)` +//! 4. On timeout, deserialize cached bytes and rebroadcast the **identical** ST +//! +//! This avoids the duplicate state transition problem that occurs when retrying +//! the all-in-one methods after a timeout (which would create a new ST with a new nonce). use crate::error::WasmSdkError; use crate::sdk::WasmSdk; use crate::settings::PutSettingsInput; +use dash_sdk::dpp::dashcore::secp256k1::rand::rngs::StdRng; +use dash_sdk::dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentType; -use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; +use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::PutDocument; @@ -26,6 +44,7 @@ use wasm_dpp2::utils::{ IntoWasm, }; use wasm_dpp2::IdentitySignerWasm; +use wasm_dpp2::StateTransitionWasm; // ============================================================================ // Document Create @@ -391,6 +410,332 @@ impl WasmSdk { } } +// ============================================================================ +// Prepare Document Create (Two-Phase API) +// ============================================================================ + +/// TypeScript interface for prepare document create options +#[wasm_bindgen(typescript_custom_section)] +const PREPARE_DOCUMENT_CREATE_OPTIONS_TS: &'static str = r#" +/** + * Options for preparing a document creation state transition without broadcasting. + * + * Use this for idempotent retry patterns: + * 1. Call `prepareDocumentCreate()` to get a signed `StateTransition` + * 2. Cache `stateTransition.toBytes()` for retry safety + * 3. Call `broadcastStateTransition(st)` + `waitForResponse(st)` + * 4. On timeout, deserialize cached bytes and rebroadcast the **identical** ST + */ +export interface PrepareDocumentCreateOptions { + /** The document to create. */ + document: Document; + /** The identity public key to use for signing. */ + identityKey: IdentityPublicKey; + /** Signer containing the private key for the identity key. */ + signer: IdentitySigner; + /** Optional settings (retries, timeouts, userFeeIncrease). */ + settings?: PutSettings; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "PrepareDocumentCreateOptions")] + pub type PrepareDocumentCreateOptionsJs; +} + +#[wasm_bindgen] +impl WasmSdk { + /// Prepare a document creation state transition without broadcasting. + /// + /// This method handles nonce management, ST construction, and signing, but does + /// **not** broadcast or wait for a response. The returned `StateTransition` can be: + /// + /// - Serialized with `toBytes()` and cached for retry safety + /// - Broadcast with `broadcastStateTransition(st)` + /// - Awaited with `waitForResponse(st)` + /// + /// This is the "prepare" half of the two-phase API. Use it when you need + /// idempotent retry behavior — on timeout, you can rebroadcast the exact same + /// signed transition instead of creating a new one with a new nonce. + /// + /// @param options - Creation options including document, identity key, and signer + /// @returns The signed StateTransition ready for broadcasting + #[wasm_bindgen(js_name = "prepareDocumentCreate")] + pub async fn prepare_document_create( + &self, + options: PrepareDocumentCreateOptionsJs, + ) -> Result { + // Extract document from options + let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + let document: Document = document_wasm.clone().into(); + + // Get metadata from document + let contract_id: Identifier = document_wasm.data_contract_id().into(); + let document_type_name = document_wasm.document_type_name(); + + // Get entropy from document + let entropy = document_wasm.entropy().ok_or_else(|| { + WasmSdkError::invalid_argument("Document must have entropy set for creation") + })?; + + if entropy.len() != 32 { + return Err(WasmSdkError::invalid_argument( + "Document entropy must be exactly 32 bytes", + )); + } + + let mut entropy_array = [0u8; 32]; + entropy_array.copy_from_slice(&entropy); + + // Extract identity key from options + let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; + let identity_key: IdentityPublicKey = identity_key_wasm.into(); + + // Extract signer from options + let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + + // Fetch the data contract (using cache) + let data_contract = self.get_or_fetch_contract(contract_id).await?; + + // Get document type (owned) + let document_type = get_document_type(&data_contract, &document_type_name)?; + + // Extract settings from options + let settings = + try_from_options_optional::(&options, "settings")?.map(Into::into); + + // Build and sign the state transition without broadcasting + let state_transition = build_document_create_or_replace_transition( + &document, + &document_type, + Some(entropy_array), + &identity_key, + &signer, + self.inner_sdk(), + settings, + ) + .await?; + + Ok(state_transition.into()) + } +} + +// ============================================================================ +// Prepare Document Replace (Two-Phase API) +// ============================================================================ + +/// TypeScript interface for prepare document replace options +#[wasm_bindgen(typescript_custom_section)] +const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#" +/** + * Options for preparing a document replace state transition without broadcasting. + * + * Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern. + */ +export interface PrepareDocumentReplaceOptions { + /** The document with updated data (same ID, incremented revision). */ + document: Document; + /** The identity public key to use for signing. */ + identityKey: IdentityPublicKey; + /** Signer containing the private key for the identity key. */ + signer: IdentitySigner; + /** Optional settings (retries, timeouts, userFeeIncrease). */ + settings?: PutSettings; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "PrepareDocumentReplaceOptions")] + pub type PrepareDocumentReplaceOptionsJs; +} + +#[wasm_bindgen] +impl WasmSdk { + /// Prepare a document replace state transition without broadcasting. + /// + /// This method handles nonce management, ST construction, and signing, but does + /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for + /// the full two-phase usage pattern. + /// + /// @param options - Replace options including document, identity key, and signer + /// @returns The signed StateTransition ready for broadcasting + #[wasm_bindgen(js_name = "prepareDocumentReplace")] + pub async fn prepare_document_replace( + &self, + options: PrepareDocumentReplaceOptionsJs, + ) -> Result { + // Extract document from options + let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + let document: Document = document_wasm.clone().into(); + + // Get metadata from document + let contract_id: Identifier = document_wasm.data_contract_id().into(); + let document_type_name = document_wasm.document_type_name(); + + // Extract identity key from options + let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; + let identity_key: IdentityPublicKey = identity_key_wasm.into(); + + // Extract signer from options + let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + + // Fetch the data contract (using cache) + let data_contract = self.get_or_fetch_contract(contract_id).await?; + + // Get document type (owned) + let document_type = get_document_type(&data_contract, &document_type_name)?; + + // Extract settings from options + let settings = + try_from_options_optional::(&options, "settings")?.map(Into::into); + + // Build and sign the state transition without broadcasting + let state_transition = build_document_create_or_replace_transition( + &document, + &document_type, + None, // entropy not needed for replace + &identity_key, + &signer, + self.inner_sdk(), + settings, + ) + .await?; + + Ok(state_transition.into()) + } +} + +// ============================================================================ +// Prepare Document Delete (Two-Phase API) +// ============================================================================ + +/// TypeScript interface for prepare document delete options +#[wasm_bindgen(typescript_custom_section)] +const PREPARE_DOCUMENT_DELETE_OPTIONS_TS: &'static str = r#" +/** + * Options for preparing a document delete state transition without broadcasting. + * + * Use this for idempotent retry patterns. See `prepareDocumentCreate` for the full pattern. + */ +export interface PrepareDocumentDeleteOptions { + /** + * The document to delete — either a Document instance or an object with identifiers. + */ + document: Document | { + id: IdentifierLike; + ownerId: IdentifierLike; + dataContractId: IdentifierLike; + documentTypeName: string; + }; + /** The identity public key to use for signing. */ + identityKey: IdentityPublicKey; + /** Signer containing the private key for the identity key. */ + signer: IdentitySigner; + /** Optional settings (retries, timeouts, userFeeIncrease). */ + settings?: PutSettings; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "PrepareDocumentDeleteOptions")] + pub type PrepareDocumentDeleteOptionsJs; +} + +#[wasm_bindgen] +impl WasmSdk { + /// Prepare a document delete state transition without broadcasting. + /// + /// This method handles nonce management, ST construction, and signing, but does + /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for + /// the full two-phase usage pattern. + /// + /// @param options - Delete options including document identifiers, identity key, and signer + /// @returns The signed StateTransition ready for broadcasting + #[wasm_bindgen(js_name = "prepareDocumentDelete")] + pub async fn prepare_document_delete( + &self, + options: PrepareDocumentDeleteOptionsJs, + ) -> Result { + // Extract document field - can be either a Document instance or plain object + let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document")) + .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; + + if document_js.is_undefined() || document_js.is_null() { + return Err(WasmSdkError::invalid_argument("document is required")); + } + + // Check if it's a Document instance or a plain object with fields + let (document_id, owner_id, contract_id, document_type_name): ( + Identifier, + Identifier, + Identifier, + String, + ) = if get_class_type(&document_js).ok().as_deref() == Some("Document") { + let doc: DocumentWasm = document_js + .to_wasm::("Document") + .map(|boxed| (*boxed).clone())?; + let doc_inner: Document = doc.clone().into(); + ( + doc.id().into(), + doc_inner.owner_id(), + doc.data_contract_id().into(), + doc.document_type_name(), + ) + } else { + ( + IdentifierWasm::try_from_options(&document_js, "id")?.into(), + IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), + IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), + try_from_options_with(&document_js, "documentTypeName", |v| { + try_to_string(v, "documentTypeName") + })?, + ) + }; + + // Extract identity key from options + let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; + let identity_key: IdentityPublicKey = identity_key_wasm.into(); + + // Extract signer from options + let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + + // Fetch the data contract (using cache) + let data_contract = self.get_or_fetch_contract(contract_id).await?; + + // Extract settings from options + let settings = + try_from_options_optional::(&options, "settings")?.map(Into::into); + + // Build the delete transition using the builder's sign method (which does NOT broadcast) + let builder = DocumentDeleteTransitionBuilder::new( + Arc::new(data_contract), + document_type_name, + document_id, + owner_id, + ); + + let builder = if let Some(s) = settings { + builder.with_settings(s) + } else { + builder + }; + + let state_transition = builder + .sign( + self.inner_sdk(), + &identity_key, + &signer, + self.inner_sdk().version(), + ) + .await?; + + Ok(state_transition.into()) + } +} + // ============================================================================ // Document Transfer // ============================================================================ @@ -742,6 +1087,83 @@ impl WasmSdk { // Helper Functions // ============================================================================ +/// Build and sign a document create or replace state transition without broadcasting. +/// +/// This replicates the ST construction logic from `PutDocument::put_to_platform` in `rs-sdk`, +/// but stops before the broadcast step. The returned `StateTransition` is fully signed and +/// ready to be broadcast via `broadcastStateTransition()`. +/// +/// Whether this produces a create or replace transition depends on the document's revision: +/// - If revision is `None` or `INITIAL_REVISION` → create transition +/// - Otherwise → replace transition +async fn build_document_create_or_replace_transition( + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: &IdentityPublicKey, + signer: &IdentitySignerWasm, + sdk: &dash_sdk::Sdk, + settings: Option, +) -> Result { + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + + let new_identity_contract_nonce = sdk + .get_identity_contract_nonce( + document.owner_id(), + document_type.data_contract_id(), + true, + settings, + ) + .await?; + + let put_settings = settings.unwrap_or_default(); + + let transition = if document.revision().is_some() + && document.revision().unwrap() != INITIAL_REVISION + { + BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type.as_ref(), + identity_public_key, + new_identity_contract_nonce, + put_settings.user_fee_increase.unwrap_or_default(), + None, // token_payment_info + signer, + sdk.version(), + put_settings.state_transition_creation_options, + ) + } else { + let (doc, entropy) = document_state_transition_entropy + .map(|entropy| (document.clone(), entropy)) + .unwrap_or_else(|| { + let mut rng = StdRng::from_entropy(); + let mut doc = document.clone(); + let entropy = rng.gen::<[u8; 32]>(); + doc.set_id(Document::generate_document_id_v0( + &document_type.data_contract_id(), + &doc.owner_id(), + document_type.name(), + entropy.as_slice(), + )); + (doc, entropy) + }); + BatchTransition::new_document_creation_transition_from_document( + doc, + document_type.as_ref(), + entropy, + identity_public_key, + new_identity_contract_nonce, + put_settings.user_fee_increase.unwrap_or_default(), + None, // token_payment_info + signer, + sdk.version(), + put_settings.state_transition_creation_options, + ) + }?; + + Ok(transition) +} + /// Get an owned DocumentType from a DataContract fn get_document_type( data_contract: &dash_sdk::platform::DataContract, From b3e62d671ecea0d478977b02c102f3f0d65c29da Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 17 Feb 2026 14:59:06 -0600 Subject: [PATCH 02/24] style: use map_or for revision check instead of is_some+unwrap Addresses CodeRabbit nitpick - more idiomatic Rust pattern that avoids calling revision() twice and the unnecessary unwrap(). --- packages/wasm-sdk/src/state_transitions/document.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 2f0a21ba60c..45caba5cd5c 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -1118,8 +1118,9 @@ async fn build_document_create_or_replace_transition( let put_settings = settings.unwrap_or_default(); - let transition = if document.revision().is_some() - && document.revision().unwrap() != INITIAL_REVISION + let transition = if document + .revision() + .map_or(false, |rev| rev != INITIAL_REVISION) { BatchTransition::new_document_replacement_transition_from_document( document.clone(), From 466c8255b2067ae49b83ce4db24d1e8c27bb60c5 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 17 Feb 2026 15:43:35 -0600 Subject: [PATCH 03/24] fix(wasm-sdk): reject create-eligible documents in prepare_document_replace Add a guard in prepare_document_replace to reject documents with no revision or INITIAL_REVISION, which would otherwise silently produce a create transition instead of a replace. Also move the inline DocumentTypeV0Getters import to module-level. Co-Authored-By: Claude Opus 4.6 --- .../wasm-sdk/src/state_transitions/document.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 45caba5cd5c..8aabbce5997 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -22,6 +22,7 @@ use crate::settings::PutSettingsInput; use dash_sdk::dpp::dashcore::secp256k1::rand::rngs::StdRng; use dash_sdk::dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentType; use dash_sdk::dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; use dash_sdk::dpp::fee::Credits; @@ -570,6 +571,18 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); + // Guard: reject documents with no revision or INITIAL_REVISION — those are creates, not replaces + let revision = document.revision().ok_or_else(|| { + WasmSdkError::invalid_argument( + "Document must have a revision set for replace. Use prepareDocumentCreate for new documents.", + ) + })?; + if revision == INITIAL_REVISION { + return Err(WasmSdkError::invalid_argument( + "Document revision is INITIAL_REVISION (1). Replace requires revision > 1. Use prepareDocumentCreate for new documents.", + )); + } + // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); let document_type_name = document_wasm.document_type_name(); @@ -1105,8 +1118,6 @@ async fn build_document_create_or_replace_transition( sdk: &dash_sdk::Sdk, settings: Option, ) -> Result { - use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; - let new_identity_contract_nonce = sdk .get_identity_contract_nonce( document.owner_id(), From 24fbe7d19f60b5e67f45302d24a4b85b13ae054a Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 20 Feb 2026 16:42:01 -0600 Subject: [PATCH 04/24] fix(wasm-sdk): use is_some_and instead of map_or to satisfy clippy Clippy 1.92 treats unnecessary_map_or as a warning, which CI promotes to error via -D warnings. --- packages/wasm-sdk/src/state_transitions/document.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 8aabbce5997..7cf0e2d8cd6 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -1131,7 +1131,7 @@ async fn build_document_create_or_replace_transition( let transition = if document .revision() - .map_or(false, |rev| rev != INITIAL_REVISION) + .is_some_and(|rev| rev != INITIAL_REVISION) { BatchTransition::new_document_replacement_transition_from_document( document.clone(), From 3d84f1bedb0e9e0f7d439aaf01e2d4c804795016 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 22 Apr 2026 16:56:38 -0500 Subject: [PATCH 05/24] fix(wasm-sdk): validate prepared document transitions --- .../src/state_transitions/document.rs | 132 ++++++++++++- .../functional/transitions/documents.spec.ts | 127 ++++++++++++ .../tests/unit/prepare-document.spec.ts | 185 ++++++++++++++++++ 3 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 packages/wasm-sdk/tests/unit/prepare-document.spec.ts diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 7cf0e2d8cd6..b49915bbd0f 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -15,6 +15,18 @@ //! //! This avoids the duplicate state transition problem that occurs when retrying //! the all-in-one methods after a timeout (which would create a new ST with a new nonce). +//! +//! ## Nonce consumption +//! +//! Every successful `prepareDocument*` call fetches a fresh identity-contract nonce +//! and advances it on the node. The signed state transition embeds that nonce and +//! is the **only** form that can be broadcast for it — a later `prepareDocument*` +//! call for the same (identity, contract) pair will consume a different nonce. +//! +//! Only call `prepareDocument*` when you intend to broadcast the returned transition +//! (or persist the bytes and retry broadcasting that exact transition). Discarding a +//! prepared transition leaks the consumed nonce. If you need a "dry run" with no +//! side effects on the node, do not use the prepare API. use crate::error::WasmSdkError; use crate::sdk::WasmSdk; @@ -460,6 +472,10 @@ impl WasmSdk { /// idempotent retry behavior — on timeout, you can rebroadcast the exact same /// signed transition instead of creating a new one with a new nonce. /// + /// **Nonce consumption:** A successful call consumes (advances) the identity-contract + /// nonce. Only call this when you intend to broadcast / persist-and-retry the returned + /// transition — discarding it leaks the nonce. See module docs for details. + /// /// @param options - Creation options including document, identity key, and signer /// @returns The signed StateTransition ready for broadcasting #[wasm_bindgen(js_name = "prepareDocumentCreate")] @@ -471,6 +487,20 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); + // Guard: reject documents with any explicit revision other than INITIAL_REVISION. + // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` + // (including `0`) to the replace branch, so the only explicit values that are safe for + // create are `None` and `INITIAL_REVISION` (1). Without this guard, a `revision = 0` + // document would silently produce a replace transition, which is not what the caller asked for. + if let Some(revision) = document.revision() { + if revision != INITIAL_REVISION { + return Err(WasmSdkError::invalid_argument(format!( + "Document revision is {} but create requires revision to be unset or {}. Use prepareDocumentReplace for existing documents.", + revision, INITIAL_REVISION, + ))); + } + } + // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); let document_type_name = document_wasm.document_type_name(); @@ -518,6 +548,10 @@ impl WasmSdk { ) .await?; + // Validate structure before handing the ST back to the caller, matching + // the check rs-sdk's `put_to_platform` performs before broadcasting. + validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; + Ok(state_transition.into()) } } @@ -560,6 +594,10 @@ impl WasmSdk { /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for /// the full two-phase usage pattern. /// + /// **Nonce consumption:** A successful call consumes (advances) the identity-contract + /// nonce. Only call this when you intend to broadcast / persist-and-retry the returned + /// transition — discarding it leaks the nonce. See module docs for details. + /// /// @param options - Replace options including document, identity key, and signer /// @returns The signed StateTransition ready for broadcasting #[wasm_bindgen(js_name = "prepareDocumentReplace")] @@ -571,16 +609,20 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); - // Guard: reject documents with no revision or INITIAL_REVISION — those are creates, not replaces + // Guard: replace requires revision > INITIAL_REVISION. Revisions 0 and 1 (and a + // missing revision) all indicate the caller really wants create semantics and are + // rejected here so we never accidentally produce a create transition from a + // replace call. let revision = document.revision().ok_or_else(|| { WasmSdkError::invalid_argument( "Document must have a revision set for replace. Use prepareDocumentCreate for new documents.", ) })?; - if revision == INITIAL_REVISION { - return Err(WasmSdkError::invalid_argument( - "Document revision is INITIAL_REVISION (1). Replace requires revision > 1. Use prepareDocumentCreate for new documents.", - )); + if revision <= INITIAL_REVISION { + return Err(WasmSdkError::invalid_argument(format!( + "Document revision is {} but replace requires revision > {}. Use prepareDocumentCreate for new documents.", + revision, INITIAL_REVISION, + ))); } // Get metadata from document @@ -616,6 +658,10 @@ impl WasmSdk { ) .await?; + // Validate structure before handing the ST back to the caller, matching + // the check rs-sdk's `put_to_platform` performs before broadcasting. + validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; + Ok(state_transition.into()) } } @@ -665,6 +711,10 @@ impl WasmSdk { /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for /// the full two-phase usage pattern. /// + /// **Nonce consumption:** A successful call consumes (advances) the identity-contract + /// nonce. Only call this when you intend to broadcast / persist-and-retry the returned + /// transition — discarding it leaks the nonce. See module docs for details. + /// /// @param options - Delete options including document identifiers, identity key, and signer /// @returns The signed StateTransition ready for broadcasting #[wasm_bindgen(js_name = "prepareDocumentDelete")] @@ -745,6 +795,10 @@ impl WasmSdk { ) .await?; + // Validate structure before handing the ST back to the caller, matching + // the check rs-sdk's `put_to_platform` performs before broadcasting. + validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; + Ok(state_transition.into()) } } @@ -1190,3 +1244,71 @@ fn get_document_type( )) }) } + +/// Validate the structure of a state transition, matching the check rs-sdk's +/// `put_to_platform` performs before broadcasting. +/// +/// rs-sdk's equivalent helper (`ensure_valid_state_transition_structure`) is +/// crate-private, so we reimplement the same logic against the public +/// `StateTransitionStructureValidation` trait. +/// +/// `UnsupportedFeatureError` is allowed through, matching rs-sdk behavior — +/// DPP does not implement structure validation for identity-based state +/// transitions, and platform still validates them during execution. +fn validate_state_transition_structure( + state_transition: &dash_sdk::dpp::state_transition::StateTransition, + platform_version: &dash_sdk::dpp::version::PlatformVersion, +) -> Result<(), WasmSdkError> { + use dash_sdk::dpp::consensus::basic::BasicError; + use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::dpp::state_transition::StateTransitionStructureValidation; + + let validation_result = state_transition.validate_structure(platform_version); + if validation_result.is_valid() { + return Ok(()); + } + + let all_unsupported = validation_result.errors.iter().all(|e| { + matches!( + e, + ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) + ) + }); + if all_unsupported { + return Ok(()); + } + + // Surface the first error through the ProtocolError → WasmSdkError conversion chain. + // `is_valid()` returned false, so `errors` is non-empty; fall back defensively. + let first = validation_result.errors.into_iter().next().ok_or_else(|| { + WasmSdkError::generic("state transition structure validation failed without an error") + })?; + Err(dash_sdk::dpp::ProtocolError::from(first).into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::state_transition::identity_credit_transfer_transition::IdentityCreditTransferTransition; + use dash_sdk::dpp::state_transition::StateTransition; + use dash_sdk::dpp::version::PlatformVersion; + + /// Regression test for the UnsupportedFeatureError pass-through path. + /// + /// DPP's `validate_structure` implementation returns `UnsupportedFeatureError` + /// for identity-based state transitions (see rs-dpp `state_transition/mod.rs` + /// `StateTransitionStructureValidation` impl). rs-sdk intentionally allows + /// these through before broadcasting; we must do the same so we don't + /// over-reject STs in the prepare paths. + #[test] + fn validate_accepts_unsupported_feature_errors() { + let version = PlatformVersion::latest(); + let st: StateTransition = IdentityCreditTransferTransition::default_versioned(version) + .expect("default versioned ICT transition") + .into(); + assert!( + validate_state_transition_structure(&st, version).is_ok(), + "identity-based STs should pass through via UnsupportedFeatureError" + ); + } +} diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index 2b5d5457541..2b946387848 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -280,4 +280,131 @@ describe('Document State Transitions', function describeDocumentStateTransitions }); }); }); + + describe('prepareDocument* transition kind selection', () => { + // The prepare* APIs return a signed StateTransition without broadcasting. + // For document ops this is always a Batch state transition; the inner + // BatchedTransition encodes whether it's a create / replace / delete. + // DocumentTransitionActionType numeric codes (see wasm-dpp2 + // document_transition.rs): Create=0, Replace=1, Delete=2. + const DOC_TRANSITION_CREATE = 0; + const DOC_TRANSITION_REPLACE = 1; + const DOC_TRANSITION_DELETE = 2; + + function firstDocTransition(st) { + expect(st.actionType).to.equal('Batch'); + const batch = sdk.BatchTransition.fromStateTransition(st); + const inner = batch.transitions[0].toTransition(); + return inner; + } + + it('prepareDocumentCreate produces a Create batched transition', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); + + const document = new sdk.Document({ + properties: { message: 'prepare create' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + + const st = await client.prepareDocumentCreate({ + document, + identityKey, + signer, + }); + + const docTransition = firstDocTransition(st); + expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_CREATE); + }); + + it('prepareDocumentReplace produces a Replace batched transition', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); + + // Create a document first so we have a real ID to target. + const seedDoc = new sdk.Document({ + properties: { message: 'prepare replace seed' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + await client.documentCreate({ document: seedDoc, identityKey, signer }); + await new Promise((resolve) => { setTimeout(resolve, 2000); }); + + const replaceDoc = new sdk.Document({ + id: seedDoc.id, + properties: { message: 'prepare replace updated' }, + documentTypeName: 'mutableNote', + revision: 2, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + + const st = await client.prepareDocumentReplace({ + document: replaceDoc, + identityKey, + signer, + }); + + const docTransition = firstDocTransition(st); + expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_REPLACE); + }); + + it('prepareDocumentDelete accepts a Document instance and produces a Delete transition', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); + + const seedDoc = new sdk.Document({ + properties: { message: 'prepare delete seed (Document)' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + await client.documentCreate({ document: seedDoc, identityKey, signer }); + await new Promise((resolve) => { setTimeout(resolve, 2000); }); + + const st = await client.prepareDocumentDelete({ + document: seedDoc, + identityKey, + signer, + }); + + const docTransition = firstDocTransition(st); + expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_DELETE); + }); + + it('prepareDocumentDelete accepts a plain identifier object and produces a Delete transition', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); + + const seedDoc = new sdk.Document({ + properties: { message: 'prepare delete seed (object)' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + await client.documentCreate({ document: seedDoc, identityKey, signer }); + await new Promise((resolve) => { setTimeout(resolve, 2000); }); + + const st = await client.prepareDocumentDelete({ + document: { + id: seedDoc.id, + ownerId: testData.identityId, + dataContractId: testContractId, + documentTypeName: 'mutableNote', + }, + identityKey, + signer, + }); + + const docTransition = firstDocTransition(st); + expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_DELETE); + }); + }); }); diff --git a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts new file mode 100644 index 00000000000..ef902e9a817 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -0,0 +1,185 @@ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +/** + * Unit tests for `prepareDocument*` input validation. + * + * These tests only cover the validation branches that fire **synchronously** + * before any network access (revision guards, entropy guards). The happy-path + * tests that need a fetched data contract live in + * `tests/functional/transitions/documents.spec.ts`. + * + * The WasmSdk is constructed via `WasmSdkBuilder.testnet().build()`, which + * does not require a live platform — we expect every call here to reject + * before any `get_or_fetch_contract` call runs. + */ + +const DUMMY_ID = '11111111111111111111111111111111'; +const DUMMY_ID_2 = '22222222222222222222222222222222'; + +function buildSigner() { + // createTestSignerAndKey needs a running platform for functional test data, + // but constructing a signer + key by hand only exercises static APIs. + const signer = new sdk.IdentitySigner(); + const privateKey = sdk.PrivateKey.fromHex( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'testnet', + ); + signer.addKey(privateKey); + + const identityKey = new sdk.IdentityPublicKey({ + keyId: 0, + purpose: 'AUTHENTICATION', + securityLevel: 'HIGH', + keyType: 'ECDSA_SECP256K1', + isReadOnly: false, + data: privateKey.getPublicKey().toBytes(), + }); + + return { signer, identityKey }; +} + +function buildDocument(overrides: Record = {}) { + return new sdk.Document({ + properties: { message: 'hello' }, + documentTypeName: 'note', + dataContractId: DUMMY_ID, + ownerId: DUMMY_ID_2, + ...overrides, + }); +} + +describe('prepareDocument* validation', function describePrepareDocumentValidation() { + this.timeout(30000); + + let client: sdk.WasmSdk; + + before(async () => { + await init(); + const builder = sdk.WasmSdkBuilder.testnet(); + client = await builder.build(); + }); + + after(() => { + if (client) { client.free(); } + }); + + describe('prepareDocumentCreate()', () => { + it('rejects a document with no entropy', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument(); + // Entropy defaults to a generated 32-byte value in the constructor; clear it + // to hit the "must have entropy set" guard in prepare_document_create. + document.entropy = null; + + try { + await client.prepareDocumentCreate({ document, identityKey, signer }); + expect.fail('expected prepareDocumentCreate to reject without entropy'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/entropy/i); + } + }); + + it('rejects a document whose entropy is not 32 bytes', async () => { + // NOTE: the Document constructor and the entropy setter both reject + // non-32-byte buffers, so the defensive length check inside + // prepare_document_create is unreachable from JS under normal use. We + // assert the outer guard here — that the SDK refuses bad entropy + // *somewhere* before broadcasting — which is the behavior callers care + // about. + try { + const document = buildDocument(); + document.entropy = new Uint8Array(16); + expect.fail('expected Document entropy setter to reject a 16-byte buffer'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/entropy/i); + } + }); + + it('rejects a document with revision > INITIAL_REVISION (would silently be a replace)', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument({ revision: 2 }); + + try { + await client.prepareDocumentCreate({ document, identityKey, signer }); + expect.fail('expected prepareDocumentCreate to reject revision > 1'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/prepareDocumentReplace/); + } + }); + + it('rejects a document with revision 0 (would silently be a replace)', async () => { + // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` + // to the replace branch, so `revision = 0` must be rejected too — otherwise it would + // silently produce a replace transition instead of a create. + const { signer, identityKey } = buildSigner(); + const document = buildDocument({ revision: 0 }); + + try { + await client.prepareDocumentCreate({ document, identityKey, signer }); + expect.fail('expected prepareDocumentCreate to reject revision 0'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/prepareDocumentReplace/); + } + }); + }); + + describe('prepareDocumentReplace()', () => { + it('rejects a document with no revision', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument(); + // The Document constructor defaults revision to 1; clear it to exercise + // the "must have a revision set" guard in prepare_document_replace. + document.revision = null; + + try { + await client.prepareDocumentReplace({ document, identityKey, signer }); + expect.fail('expected prepareDocumentReplace to reject missing revision'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + } + }); + + it('rejects a document with revision 0', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument({ revision: 0 }); + + try { + await client.prepareDocumentReplace({ document, identityKey, signer }); + expect.fail('expected prepareDocumentReplace to reject revision 0'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/prepareDocumentCreate/); + } + }); + + it('rejects a document with revision 1 (INITIAL_REVISION)', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument({ revision: 1 }); + + try { + await client.prepareDocumentReplace({ document, identityKey, signer }); + expect.fail('expected prepareDocumentReplace to reject revision 1'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/prepareDocumentCreate/); + } + }); + }); +}); From ed7dc8cd36108330ee93362f7b2b6dc2a3d89900 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Thu, 23 Apr 2026 20:35:36 -0500 Subject: [PATCH 06/24] fix(wasm-sdk): tighten prepared document transition retries --- .../src/state_transitions/document.rs | 172 +++++++++++++----- .../functional/transitions/documents.spec.ts | 53 ++++++ .../tests/unit/prepare-document.spec.ts | 40 +++- 3 files changed, 219 insertions(+), 46 deletions(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index b49915bbd0f..cc3af81b5e1 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -124,6 +124,8 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); + ensure_document_create_revision(document.revision(), "documentReplace")?; + // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); let document_type_name = document_wasm.document_type_name(); @@ -241,6 +243,8 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); + ensure_document_replace_revision(document.revision(), "documentCreate")?; + // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); let document_type_name = document_wasm.document_type_name(); @@ -487,19 +491,7 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); - // Guard: reject documents with any explicit revision other than INITIAL_REVISION. - // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` - // (including `0`) to the replace branch, so the only explicit values that are safe for - // create are `None` and `INITIAL_REVISION` (1). Without this guard, a `revision = 0` - // document would silently produce a replace transition, which is not what the caller asked for. - if let Some(revision) = document.revision() { - if revision != INITIAL_REVISION { - return Err(WasmSdkError::invalid_argument(format!( - "Document revision is {} but create requires revision to be unset or {}. Use prepareDocumentReplace for existing documents.", - revision, INITIAL_REVISION, - ))); - } - } + ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?; // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); @@ -548,9 +540,18 @@ impl WasmSdk { ) .await?; - // Validate structure before handing the ST back to the caller, matching - // the check rs-sdk's `put_to_platform` performs before broadcasting. - validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; + // Validate structure before handing the ST back, mirroring rs-sdk's + // pre-broadcast check. For document Batch transitions this currently + // ends up as a no-op because DPP returns UnsupportedFeatureError until + // that structure validation is implemented there. + if let Err(err) = + validate_state_transition_structure(&state_transition, self.inner_sdk().version()) + { + self.inner_sdk() + .refresh_identity_nonce(&document.owner_id()) + .await; + return Err(err); + } Ok(state_transition.into()) } @@ -609,21 +610,7 @@ impl WasmSdk { let document_wasm = DocumentWasm::try_from_options(&options, "document")?; let document: Document = document_wasm.clone().into(); - // Guard: replace requires revision > INITIAL_REVISION. Revisions 0 and 1 (and a - // missing revision) all indicate the caller really wants create semantics and are - // rejected here so we never accidentally produce a create transition from a - // replace call. - let revision = document.revision().ok_or_else(|| { - WasmSdkError::invalid_argument( - "Document must have a revision set for replace. Use prepareDocumentCreate for new documents.", - ) - })?; - if revision <= INITIAL_REVISION { - return Err(WasmSdkError::invalid_argument(format!( - "Document revision is {} but replace requires revision > {}. Use prepareDocumentCreate for new documents.", - revision, INITIAL_REVISION, - ))); - } + ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?; // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); @@ -658,9 +645,18 @@ impl WasmSdk { ) .await?; - // Validate structure before handing the ST back to the caller, matching - // the check rs-sdk's `put_to_platform` performs before broadcasting. - validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; + // Validate structure before handing the ST back, mirroring rs-sdk's + // pre-broadcast check. For document Batch transitions this currently + // ends up as a no-op because DPP returns UnsupportedFeatureError until + // that structure validation is implemented there. + if let Err(err) = + validate_state_transition_structure(&state_transition, self.inner_sdk().version()) + { + self.inner_sdk() + .refresh_identity_nonce(&document.owner_id()) + .await; + return Err(err); + } Ok(state_transition.into()) } @@ -795,8 +791,10 @@ impl WasmSdk { ) .await?; - // Validate structure before handing the ST back to the caller, matching - // the check rs-sdk's `put_to_platform` performs before broadcasting. + // Validate structure before handing the ST back, mirroring rs-sdk's + // pre-broadcast check. For document Batch transitions this currently + // ends up as a no-op because DPP returns UnsupportedFeatureError until + // that structure validation is implemented there. validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; Ok(state_transition.into()) @@ -1163,6 +1161,9 @@ impl WasmSdk { /// Whether this produces a create or replace transition depends on the document's revision: /// - If revision is `None` or `INITIAL_REVISION` → create transition /// - Otherwise → replace transition +/// +/// Any error after bumping the identity-contract nonce refreshes the nonce cache, +/// mirroring rs-sdk's refresh-on-broadcast-failure idea for this prepare-path failure. async fn build_document_create_or_replace_transition( document: &Document, document_type: &DocumentType, @@ -1180,10 +1181,11 @@ async fn build_document_create_or_replace_transition( settings, ) .await?; + let owner_id = document.owner_id(); let put_settings = settings.unwrap_or_default(); - let transition = if document + let transition_result = if document .revision() .is_some_and(|rev| rev != INITIAL_REVISION) { @@ -1225,11 +1227,56 @@ async fn build_document_create_or_replace_transition( sdk.version(), put_settings.state_transition_creation_options, ) - }?; + }; + + let transition = match transition_result { + Ok(transition) => transition, + Err(err) => { + sdk.refresh_identity_nonce(&owner_id).await; + return Err(err.into()); + } + }; Ok(transition) } +fn ensure_document_create_revision( + revision: Option, + replace_api_name: &str, +) -> Result<(), WasmSdkError> { + if let Some(revision) = revision { + if revision != INITIAL_REVISION { + return Err(WasmSdkError::invalid_argument(format!( + "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", + revision, INITIAL_REVISION, replace_api_name, + ))); + } + } + + Ok(()) +} + +fn ensure_document_replace_revision( + revision: Option, + create_api_name: &str, +) -> Result<(), WasmSdkError> { + let revision = revision.ok_or_else(|| { + WasmSdkError::invalid_argument(format!( + "Document must have a revision set for replace. Use {} for new documents.", + create_api_name, + )) + })?; + + if revision <= INITIAL_REVISION { + return Err(WasmSdkError::invalid_argument(format!( + "Document revision is {} but replace requires revision > {}. Use {} for new documents.", + revision, INITIAL_REVISION, create_api_name, + ))); + } + + Ok(()) +} + /// Get an owned DocumentType from a DataContract fn get_document_type( data_contract: &dash_sdk::platform::DataContract, @@ -1245,16 +1292,18 @@ fn get_document_type( }) } -/// Validate the structure of a state transition, matching the check rs-sdk's -/// `put_to_platform` performs before broadcasting. +/// Validate the structure of a state transition, mirroring the same +/// pre-broadcast check rs-sdk performs. /// /// rs-sdk's equivalent helper (`ensure_valid_state_transition_structure`) is /// crate-private, so we reimplement the same logic against the public /// `StateTransitionStructureValidation` trait. /// /// `UnsupportedFeatureError` is allowed through, matching rs-sdk behavior — -/// DPP does not implement structure validation for identity-based state -/// transitions, and platform still validates them during execution. +/// DPP currently returns it for transition kinds whose structure validation is +/// not implemented yet. That means this helper is currently a no-op for +/// document Batch transitions used by `prepareDocumentCreate/Replace/Delete` +/// until DPP adds that validation; platform still validates them during execution. fn validate_state_transition_structure( state_transition: &dash_sdk::dpp::state_transition::StateTransition, platform_version: &dash_sdk::dpp::version::PlatformVersion, @@ -1293,6 +1342,45 @@ mod tests { use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::dpp::version::PlatformVersion; + #[test] + fn create_revision_guard_accepts_none_and_initial_revision() { + assert!(ensure_document_create_revision(None, "prepareDocumentReplace").is_ok()); + assert!( + ensure_document_create_revision(Some(INITIAL_REVISION), "prepareDocumentReplace") + .is_ok() + ); + } + + #[test] + fn create_revision_guard_rejects_non_initial_revision() { + let err = ensure_document_create_revision(Some(0), "prepareDocumentReplace") + .expect_err("revision 0 should fail"); + assert!(err.to_string().contains("prepareDocumentReplace")); + assert!(err.to_string().contains("create requires revision")); + } + + #[test] + fn replace_revision_guard_accepts_only_greater_than_initial_revision() { + assert!(ensure_document_replace_revision( + Some(INITIAL_REVISION + 1), + "prepareDocumentCreate" + ) + .is_ok()); + } + + #[test] + fn replace_revision_guard_rejects_missing_or_initial_revision() { + let missing = ensure_document_replace_revision(None, "prepareDocumentCreate") + .expect_err("missing revision should fail"); + assert!(missing.to_string().contains("prepareDocumentCreate")); + + let initial = + ensure_document_replace_revision(Some(INITIAL_REVISION), "prepareDocumentCreate") + .expect_err("initial revision should fail"); + assert!(initial.to_string().contains("prepareDocumentCreate")); + assert!(initial.to_string().contains("replace requires revision")); + } + /// Regression test for the UnsupportedFeatureError pass-through path. /// /// DPP's `validate_structure` implementation returns `UnsupportedFeatureError` diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index 2b946387848..446d8aacc08 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -298,6 +298,16 @@ describe('Document State Transitions', function describeDocumentStateTransitions return inner; } + function reloadPreparedBatchStateTransition(st) { + const bytes = st.toBytes(); + const restoredBatch = sdk.BatchTransition.fromBase64(Buffer.from(bytes).toString('base64')); + const restoredStateTransition = restoredBatch.toStateTransition(); + + expect(Buffer.from(restoredStateTransition.toBytes())).to.deep.equal(Buffer.from(bytes)); + + return restoredStateTransition; + } + it('prepareDocumentCreate produces a Create batched transition', async () => { expect(testContractId).to.exist(); const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); @@ -406,5 +416,48 @@ describe('Document State Transitions', function describeDocumentStateTransitions const docTransition = firstDocTransition(st); expect(docTransition.actionTypeNumber).to.equal(DOC_TRANSITION_DELETE); }); + + it('prepareDocumentCreate can be serialized, reloaded, broadcast, and re-broadcast without duplicating the document effect', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); + + const document = new sdk.Document({ + properties: { message: 'prepare create rebroadcast' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + + const prepared = await client.prepareDocumentCreate({ + document, + identityKey, + signer, + }); + + const restored = reloadPreparedBatchStateTransition(prepared); + + await client.broadcastStateTransition(restored); + await client.waitForResponse(restored); + await new Promise((resolve) => { setTimeout(resolve, 2000); }); + + const created = await client.getDocument(testContractId, 'mutableNote', document.id); + expect(created).to.exist(); + + try { + await client.broadcastStateTransition(restored); + } catch (e) { + // Re-broadcasting the identical prepared ST is allowed to fail with a + // duplicate / already-known style error. The important assertion is + // that it does not create a second document effect. + expect(String(e?.message ?? e)).to.match(/already|duplicate|exists|known|cache/i); + } + + await new Promise((resolve) => { setTimeout(resolve, 2000); }); + + const fetchedAgain = await client.getDocument(testContractId, 'mutableNote', document.id); + expect(fetchedAgain).to.exist(); + expect(Buffer.from(fetchedAgain.id.toBytes())).to.deep.equal(Buffer.from(document.id.toBytes())); + }); }); }); diff --git a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts index ef902e9a817..54f18345d06 100644 --- a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -2,12 +2,12 @@ import { expect } from './helpers/chai.ts'; import init, * as sdk from '../../dist/sdk.compressed.js'; /** - * Unit tests for `prepareDocument*` input validation. + * Unit tests for synchronous document transition input validation. * * These tests only cover the validation branches that fire **synchronously** - * before any network access (revision guards, entropy guards). The happy-path - * tests that need a fetched data contract live in - * `tests/functional/transitions/documents.spec.ts`. + * before any network access (revision guards, entropy guards) for the prepare + * and one-shot APIs. The happy-path tests that need a fetched data contract + * live in `tests/functional/transitions/documents.spec.ts`. * * The WasmSdk is constructed via `WasmSdkBuilder.testnet().build()`, which * does not require a live platform — we expect every call here to reject @@ -182,4 +182,36 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); }); + + describe('documentCreate()/documentReplace()', () => { + it('documentCreate rejects a document with revision 0', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument({ revision: 0 }); + + try { + await client.documentCreate({ document, identityKey, signer }); + expect.fail('expected documentCreate to reject revision 0'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/documentReplace/); + } + }); + + it('documentReplace rejects a document with revision 1 (INITIAL_REVISION)', async () => { + const { signer, identityKey } = buildSigner(); + const document = buildDocument({ revision: 1 }); + + try { + await client.documentReplace({ document, identityKey, signer }); + expect.fail('expected documentReplace to reject revision 1'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/documentCreate/); + } + }); + }); }); From b6d0637bb4e9fa4d2aa259a6fe242e5efac1b7a9 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 24 Apr 2026 05:53:37 -0500 Subject: [PATCH 07/24] fix(wasm-sdk): refresh delete nonce cache on prepare failure --- .../wasm-sdk/src/state_transitions/document.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index cc3af81b5e1..de842a53001 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -782,20 +782,32 @@ impl WasmSdk { builder }; - let state_transition = builder + let state_transition = match builder .sign( self.inner_sdk(), &identity_key, &signer, self.inner_sdk().version(), ) - .await?; + .await + { + Ok(st) => st, + Err(err) => { + self.inner_sdk().refresh_identity_nonce(&owner_id).await; + return Err(err.into()); + } + }; // Validate structure before handing the ST back, mirroring rs-sdk's // pre-broadcast check. For document Batch transitions this currently // ends up as a no-op because DPP returns UnsupportedFeatureError until // that structure validation is implemented there. - validate_state_transition_structure(&state_transition, self.inner_sdk().version())?; + if let Err(err) = + validate_state_transition_structure(&state_transition, self.inner_sdk().version()) + { + self.inner_sdk().refresh_identity_nonce(&owner_id).await; + return Err(err); + } Ok(state_transition.into()) } From bec5fb925e63330566f8645059f5fa464f3e56de Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 8 May 2026 02:15:38 -0500 Subject: [PATCH 08/24] fix(wasm-sdk): support token payments in prepare document APIs --- .../platform/documents/transitions/delete.rs | 44 +++++++++++++++++++ .../src/state_transitions/document.rs | 23 +++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 2d44ec735a5..2845a86373c 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -123,6 +123,8 @@ impl DocumentDeleteTransitionBuilder { /// /// * `Self` - The updated builder pub fn with_settings(mut self, settings: PutSettings) -> Self { + self.user_fee_increase = settings.user_fee_increase; + self.state_transition_creation_options = settings.state_transition_creation_options; self.settings = Some(settings); self } @@ -219,6 +221,48 @@ pub enum DocumentDeleteResult { Deleted(Identifier), } +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::transition::put_settings::PutSettings; + use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::PlatformVersion; + + #[test] + fn with_settings_propagates_signed_transition_fields() { + let settings = PutSettings { + user_fee_increase: Some(42), + state_transition_creation_options: Some(StateTransitionCreationOptions::default()), + ..Default::default() + }; + let data_contract = get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(); + + let builder = DocumentDeleteTransitionBuilder::new( + Arc::new(data_contract), + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_settings(settings); + + assert_eq!( + builder.settings.map(|settings| settings.user_fee_increase), + Some(settings.user_fee_increase) + ); + assert_eq!(builder.user_fee_increase, settings.user_fee_increase); + assert_eq!( + builder.state_transition_creation_options, + settings.state_transition_creation_options + ); + } +} + impl Sdk { /// Deletes an existing document from the platform. /// diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 28bde66e5fb..bdf24bd6ccd 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -536,6 +536,8 @@ export interface PrepareDocumentCreateOptions { identityKey: IdentityPublicKey; /** Signer containing the private key for the identity key. */ signer: IdentitySigner; + /** Optional token payment agreement for document types with tokenCost.create. */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; /** Optional settings (retries, timeouts, userFeeIncrease). */ settings?: PutSettings; } @@ -613,6 +615,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build and sign the state transition without broadcasting let state_transition = build_document_create_or_replace_transition( @@ -620,6 +623,7 @@ impl WasmSdk { &document_type, Some(entropy_array), &identity_key, + token_payment_info, &signer, self.inner_sdk(), settings, @@ -662,6 +666,8 @@ export interface PrepareDocumentReplaceOptions { identityKey: IdentityPublicKey; /** Signer containing the private key for the identity key. */ signer: IdentitySigner; + /** Optional token payment agreement for document types with tokenCost.replace. */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; /** Optional settings (retries, timeouts, userFeeIncrease). */ settings?: PutSettings; } @@ -718,6 +724,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build and sign the state transition without broadcasting let state_transition = build_document_create_or_replace_transition( @@ -725,6 +732,7 @@ impl WasmSdk { &document_type, None, // entropy not needed for replace &identity_key, + token_payment_info, &signer, self.inner_sdk(), settings, @@ -774,6 +782,8 @@ export interface PrepareDocumentDeleteOptions { identityKey: IdentityPublicKey; /** Signer containing the private key for the identity key. */ signer: IdentitySigner; + /** Optional token payment agreement for document types with tokenCost.delete. */ + tokenPaymentInfo?: DocumentTokenPaymentInfo; /** Optional settings (retries, timeouts, userFeeIncrease). */ settings?: PutSettings; } @@ -853,6 +863,7 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build the delete transition using the builder's sign method (which does NOT broadcast) let builder = DocumentDeleteTransitionBuilder::new( @@ -862,6 +873,12 @@ impl WasmSdk { owner_id, ); + let builder = if let Some(token_payment_info) = token_payment_info { + builder.with_token_payment_info(token_payment_info) + } else { + builder + }; + let builder = if let Some(s) = settings { builder.with_settings(s) } else { @@ -1280,11 +1297,13 @@ impl WasmSdk { /// /// Any error after bumping the identity-contract nonce refreshes the nonce cache, /// mirroring rs-sdk's refresh-on-broadcast-failure idea for this prepare-path failure. +#[allow(clippy::too_many_arguments)] async fn build_document_create_or_replace_transition( document: &Document, document_type: &DocumentType, document_state_transition_entropy: Option<[u8; 32]>, identity_public_key: &IdentityPublicKey, + token_payment_info: Option, signer: &IdentitySignerWasm, sdk: &dash_sdk::Sdk, settings: Option, @@ -1311,7 +1330,7 @@ async fn build_document_create_or_replace_transition( identity_public_key, new_identity_contract_nonce, put_settings.user_fee_increase.unwrap_or_default(), - None, // token_payment_info + token_payment_info, signer, sdk.version(), put_settings.state_transition_creation_options, @@ -1339,7 +1358,7 @@ async fn build_document_create_or_replace_transition( identity_public_key, new_identity_contract_nonce, put_settings.user_fee_increase.unwrap_or_default(), - None, // token_payment_info + token_payment_info, signer, sdk.version(), put_settings.state_transition_creation_options, From b828e8ce34f5e5bb89dff53a08d790a4297d9a95 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Fri, 8 May 2026 03:46:06 -0500 Subject: [PATCH 09/24] test(wasm-sdk): cover prepared token-paid documents --- .../src/state_transitions/document.rs | 39 +++--- .../functional/transitions/documents.spec.ts | 122 ++++++++++++++++-- 2 files changed, 135 insertions(+), 26 deletions(-) diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index bdf24bd6ccd..e1282f26638 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -18,15 +18,18 @@ //! //! ## Nonce consumption //! -//! Every successful `prepareDocument*` call fetches a fresh identity-contract nonce -//! and advances it on the node. The signed state transition embeds that nonce and -//! is the **only** form that can be broadcast for it — a later `prepareDocument*` -//! call for the same (identity, contract) pair will consume a different nonce. +//! Every successful `prepareDocument*` call resolves the next identity-contract nonce +//! for this SDK instance and advances its local nonce cache. If that cache is empty +//! or stale, the SDK may first fetch the current nonce from Platform. The signed state +//! transition embeds that nonce, but Platform state is not mutated until the transition +//! is actually broadcast and processed. //! //! Only call `prepareDocument*` when you intend to broadcast the returned transition //! (or persist the bytes and retry broadcasting that exact transition). Discarding a -//! prepared transition leaks the consumed nonce. If you need a "dry run" with no -//! side effects on the node, do not use the prepare API. +//! prepared transition leaves this SDK instance's local nonce cache ahead until it is +//! refreshed, but it does not reserve or consume the nonce remotely on Platform. If +//! you need a "dry run" with no local nonce-cache side effects in this SDK instance, +//! do not use the prepare API. use crate::error::WasmSdkError; use crate::sdk::WasmSdk; @@ -564,9 +567,11 @@ impl WasmSdk { /// idempotent retry behavior — on timeout, you can rebroadcast the exact same /// signed transition instead of creating a new one with a new nonce. /// - /// **Nonce consumption:** A successful call consumes (advances) the identity-contract - /// nonce. Only call this when you intend to broadcast / persist-and-retry the returned - /// transition — discarding it leaks the nonce. See module docs for details. + /// **Nonce consumption:** A successful call advances this SDK instance's local + /// identity-contract nonce cache and embeds that nonce in the signed transition. + /// Platform state is not mutated until broadcast/processing. Only call this when + /// you intend to broadcast / persist-and-retry the returned transition. See module + /// docs for details. /// /// @param options - Creation options including document, identity key, and signer /// @returns The signed StateTransition ready for broadcasting @@ -687,9 +692,11 @@ impl WasmSdk { /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for /// the full two-phase usage pattern. /// - /// **Nonce consumption:** A successful call consumes (advances) the identity-contract - /// nonce. Only call this when you intend to broadcast / persist-and-retry the returned - /// transition — discarding it leaks the nonce. See module docs for details. + /// **Nonce consumption:** A successful call advances this SDK instance's local + /// identity-contract nonce cache and embeds that nonce in the signed transition. + /// Platform state is not mutated until broadcast/processing. Only call this when + /// you intend to broadcast / persist-and-retry the returned transition. See module + /// docs for details. /// /// @param options - Replace options including document, identity key, and signer /// @returns The signed StateTransition ready for broadcasting @@ -803,9 +810,11 @@ impl WasmSdk { /// **not** broadcast or wait for a response. See `prepareDocumentCreate` for /// the full two-phase usage pattern. /// - /// **Nonce consumption:** A successful call consumes (advances) the identity-contract - /// nonce. Only call this when you intend to broadcast / persist-and-retry the returned - /// transition — discarding it leaks the nonce. See module docs for details. + /// **Nonce consumption:** A successful call advances this SDK instance's local + /// identity-contract nonce cache and embeds that nonce in the signed transition. + /// Platform state is not mutated until broadcast/processing. Only call this when + /// you intend to broadcast / persist-and-retry the returned transition. See module + /// docs for details. /// /// @param options - Delete options including document identifiers, identity key, and signer /// @returns The signed StateTransition ready for broadcasting diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index 8231e462a4e..e8ea311e644 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -26,6 +26,21 @@ describe('Document State Transitions', function describeDocumentStateTransitions let client: sdk.WasmSdk; const testData = wasmFunctionalTestRequirements(); const waitForPlatform = async (ms = 2000) => new Promise((resolve) => { setTimeout(resolve, ms); }); + const reloadPreparedStateTransition = (st) => { + const bytes = st.toBytes(); + const restoredBatch = sdk.BatchTransition.fromBase64(Buffer.from(bytes).toString('base64')); + const restoredStateTransition = restoredBatch.toStateTransition(); + + expect(Buffer.from(restoredStateTransition.toBytes())).to.deep.equal(Buffer.from(bytes)); + + return restoredStateTransition; + }; + const broadcastPreparedStateTransition = async (st) => { + const restored = reloadPreparedStateTransition(st); + await client.broadcastStateTransition(restored); + await client.waitForResponse(restored); + return restored; + }; const getSingleTokenBalance = async (identityId: string, tokenId: string) => { const balances = await client.getIdentityTokenBalances(identityId, [tokenId]); return balances.get(tokenId); @@ -357,16 +372,6 @@ describe('Document State Transitions', function describeDocumentStateTransitions return inner; } - function reloadPreparedBatchStateTransition(st) { - const bytes = st.toBytes(); - const restoredBatch = sdk.BatchTransition.fromBase64(Buffer.from(bytes).toString('base64')); - const restoredStateTransition = restoredBatch.toStateTransition(); - - expect(Buffer.from(restoredStateTransition.toBytes())).to.deep.equal(Buffer.from(bytes)); - - return restoredStateTransition; - } - it('prepareDocumentCreate produces a Create batched transition', async () => { expect(testContractId).to.exist(); const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); @@ -494,7 +499,7 @@ describe('Document State Transitions', function describeDocumentStateTransitions signer, }); - const restored = reloadPreparedBatchStateTransition(prepared); + const restored = reloadPreparedStateTransition(prepared); await client.broadcastStateTransition(restored); await client.waitForResponse(restored); @@ -875,5 +880,100 @@ describe('Document State Transitions', function describeDocumentStateTransitions expect(await getSingleTokenBalance(testData.identityId3, tokenPaidTokenId)).to.equal(47n); expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(927n); }); + + it('should prepare, broadcast, replace, and delete token-priced documents with tokenPaymentInfo', async () => { + expect(tokenPaidContractId).to.exist(); + expect(tokenPaidTokenId).to.exist(); + + const { signer: sellerDocSigner, identityKey: sellerDocKey } = createTestSignerAndKey(sdk, 2, 2); + const sellerBalanceBefore = await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId); + const ownerBalanceBefore = await getSingleTokenBalance(testData.identityId, tokenPaidTokenId); + + const listingTitle = `Prepared token paid listing ${Date.now()}`; + const preparedDocument = new sdk.Document({ + properties: { title: listingTitle }, + documentTypeName: 'tokenPaidListing', + revision: 1, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + }); + + const preparedCreate = await client.prepareDocumentCreate({ + document: preparedDocument, + identityKey: sellerDocKey, + signer: sellerDocSigner, + tokenPaymentInfo: makeTokenPaymentInfo(5n), + }); + + await broadcastPreparedStateTransition(preparedCreate); + + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(sellerBalanceBefore - 5n); + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(ownerBalanceBefore + 5n); + + await waitForPlatform(); + + const createdPreparedDocument = await client.getDocument( + tokenPaidContractId, + 'tokenPaidListing', + preparedDocument.id, + ); + expect(createdPreparedDocument).to.exist(); + expect(createdPreparedDocument.properties.title).to.equal(listingTitle); + + const replacedTitle = `${listingTitle} updated`; + const replaceDocument = new sdk.Document({ + properties: { title: replacedTitle }, + documentTypeName: 'tokenPaidListing', + revision: 2, + dataContractId: tokenPaidContractId, + ownerId: testData.identityId2, + id: preparedDocument.id, + }); + + const preparedReplace = await client.prepareDocumentReplace({ + document: replaceDocument, + identityKey: sellerDocKey, + signer: sellerDocSigner, + tokenPaymentInfo: makeTokenPaymentInfo(4n), + }); + + await broadcastPreparedStateTransition(preparedReplace); + + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(sellerBalanceBefore - 9n); + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(ownerBalanceBefore + 9n); + + await waitForPlatform(); + + const replacedPreparedDocument = await client.getDocument( + tokenPaidContractId, + 'tokenPaidListing', + preparedDocument.id, + ); + expect(replacedPreparedDocument).to.exist(); + expect(replacedPreparedDocument.properties.title).to.equal(replacedTitle); + + const preparedDelete = await client.prepareDocumentDelete({ + document: { + id: preparedDocument.id, + ownerId: testData.identityId2, + dataContractId: tokenPaidContractId, + documentTypeName: 'tokenPaidListing', + }, + identityKey: sellerDocKey, + signer: sellerDocSigner, + tokenPaymentInfo: makeTokenPaymentInfo(1n), + }); + + await broadcastPreparedStateTransition(preparedDelete); + + expect(await getSingleTokenBalance(testData.identityId2, tokenPaidTokenId)).to.equal(sellerBalanceBefore - 10n); + expect(await getSingleTokenBalance(testData.identityId, tokenPaidTokenId)).to.equal(ownerBalanceBefore + 10n); + + await waitForPlatform(); + + await expect( + client.getDocument(tokenPaidContractId, 'tokenPaidListing', preparedDocument.id), + ).to.be.rejected(); + }); }); }); From d3d33002e9217a9803ba42dd0ab3b7b6cae7dcf8 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 9 May 2026 13:44:13 -0500 Subject: [PATCH 10/24] test(wasm-sdk): cover prepared document rebroadcasts --- .../platform/documents/transitions/delete.rs | 67 +++++++-- .../functional/transitions/documents.spec.ts | 99 ++++++++++--- .../tests/unit/prepare-document.spec.ts | 130 ++++++++++++++++++ 3 files changed, 271 insertions(+), 25 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 2845a86373c..b55b8aa635c 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -1,18 +1,18 @@ +use crate::platform::Identifier; use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; -use crate::platform::Identifier; use crate::{Error, Sdk}; -use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::document::{Document, INITIAL_REVISION}; -use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; +use dpp::identity::signer::Signer; use dpp::prelude::UserFeeIncrease; -use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; -use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dpp::state_transition::StateTransition; use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::proof_result::StateTransitionProofResult; -use dpp::state_transition::StateTransition; use dpp::tokens::token_payment_info::TokenPaymentInfo; use dpp::version::PlatformVersion; use std::sync::Arc; @@ -123,8 +123,13 @@ impl DocumentDeleteTransitionBuilder { /// /// * `Self` - The updated builder pub fn with_settings(mut self, settings: PutSettings) -> Self { - self.user_fee_increase = settings.user_fee_increase; - self.state_transition_creation_options = settings.state_transition_creation_options; + if let Some(user_fee_increase) = settings.user_fee_increase { + self.user_fee_increase = Some(user_fee_increase); + } + if let Some(state_transition_creation_options) = settings.state_transition_creation_options + { + self.state_transition_creation_options = Some(state_transition_creation_options); + } self.settings = Some(settings); self } @@ -261,6 +266,52 @@ mod tests { settings.state_transition_creation_options ); } + + #[test] + fn with_settings_preserves_explicit_fields_when_settings_values_are_none() { + let explicit_creation_options = StateTransitionCreationOptions::default(); + let builder = DocumentDeleteTransitionBuilder::new( + Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ), + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_user_fee_increase(42) + .with_state_transition_creation_options(explicit_creation_options) + .with_settings(PutSettings { + user_fee_increase: None, + state_transition_creation_options: None, + ..Default::default() + }); + + assert_eq!(builder.user_fee_increase, Some(42)); + assert_eq!( + builder.state_transition_creation_options, + Some(explicit_creation_options) + ); + assert!(builder.settings.is_some()); + assert_eq!( + builder + .settings + .as_ref() + .and_then(|settings| settings.user_fee_increase), + None + ); + assert_eq!( + builder + .settings + .as_ref() + .and_then(|settings| settings.state_transition_creation_options), + None + ); + } } impl Sdk { diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index e8ea311e644..da78f8e2c97 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -372,6 +372,22 @@ describe('Document State Transitions', function describeDocumentStateTransitions return inner; } + async function expectPreparedRebroadcastToBeIdempotent(st) { + const restored = reloadPreparedStateTransition(st); + + await client.broadcastStateTransition(restored); + await client.waitForResponse(restored); + + try { + await client.broadcastStateTransition(restored); + await client.waitForResponse(restored); + } catch (e) { + expect(String(e?.message ?? e)).to.match(/already|duplicate|exists|known|cache/i); + } + + await waitForPlatform(); + } + it('prepareDocumentCreate produces a Create batched transition', async () => { expect(testContractId).to.exist(); const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); @@ -499,29 +515,78 @@ describe('Document State Transitions', function describeDocumentStateTransitions signer, }); - const restored = reloadPreparedStateTransition(prepared); + await expectPreparedRebroadcastToBeIdempotent(prepared); - await client.broadcastStateTransition(restored); - await client.waitForResponse(restored); - await new Promise((resolve) => { setTimeout(resolve, 2000); }); + const fetchedAgain = await client.getDocument(testContractId, 'mutableNote', document.id); + expect(fetchedAgain).to.exist(); + expect(Buffer.from(fetchedAgain.id.toBytes())).to.deep.equal(Buffer.from(document.id.toBytes())); + expect(Number(fetchedAgain.revision)).to.equal(1); + expect(fetchedAgain.properties.message).to.equal(document.properties.message); + }); - const created = await client.getDocument(testContractId, 'mutableNote', document.id); - expect(created).to.exist(); + it('prepareDocumentReplace can be serialized, reloaded, broadcast, and re-broadcast without duplicating the replace effect', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); - try { - await client.broadcastStateTransition(restored); - } catch (e) { - // Re-broadcasting the identical prepared ST is allowed to fail with a - // duplicate / already-known style error. The important assertion is - // that it does not create a second document effect. - expect(String(e?.message ?? e)).to.match(/already|duplicate|exists|known|cache/i); - } + const seedDoc = new sdk.Document({ + properties: { message: 'prepare replace rebroadcast seed' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + await client.documentCreate({ document: seedDoc, identityKey, signer }); + await waitForPlatform(); - await new Promise((resolve) => { setTimeout(resolve, 2000); }); + const replaceDoc = new sdk.Document({ + id: seedDoc.id, + properties: { message: 'prepare replace rebroadcast updated' }, + documentTypeName: 'mutableNote', + revision: 2, + dataContractId: testContractId, + ownerId: testData.identityId, + }); - const fetchedAgain = await client.getDocument(testContractId, 'mutableNote', document.id); + const prepared = await client.prepareDocumentReplace({ + document: replaceDoc, + identityKey, + signer, + }); + + await expectPreparedRebroadcastToBeIdempotent(prepared); + + const fetchedAgain = await client.getDocument(testContractId, 'mutableNote', seedDoc.id); expect(fetchedAgain).to.exist(); - expect(Buffer.from(fetchedAgain.id.toBytes())).to.deep.equal(Buffer.from(document.id.toBytes())); + expect(Buffer.from(fetchedAgain.id.toBytes())).to.deep.equal(Buffer.from(seedDoc.id.toBytes())); + expect(Number(fetchedAgain.revision)).to.equal(2); + expect(fetchedAgain.properties.message).to.equal(replaceDoc.properties.message); + }); + + it('prepareDocumentDelete can be serialized, reloaded, broadcast, and re-broadcast without reviving the document', async () => { + expect(testContractId).to.exist(); + const { signer, identityKey } = createTestSignerAndKey(sdk, 1, 2); + + const seedDoc = new sdk.Document({ + properties: { message: 'prepare delete rebroadcast seed' }, + documentTypeName: 'mutableNote', + revision: 1, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + await client.documentCreate({ document: seedDoc, identityKey, signer }); + await waitForPlatform(); + + const prepared = await client.prepareDocumentDelete({ + document: seedDoc, + identityKey, + signer, + }); + + await expectPreparedRebroadcastToBeIdempotent(prepared); + + await expect( + client.getDocument(testContractId, 'mutableNote', seedDoc.id), + ).to.be.rejected(); }); }); diff --git a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts index 54f18345d06..cddbf167346 100644 --- a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -183,6 +183,136 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati }); }); + describe('prepareDocumentDelete()', () => { + it('rejects when document is missing', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ identityKey, signer }); + expect.fail('expected prepareDocumentDelete to reject without document'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/document is required/i); + } + }); + + it('rejects when document is null', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ document: null, identityKey, signer }); + expect.fail('expected prepareDocumentDelete to reject null document'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/document is required/i); + } + }); + + it('rejects a plain object with no id', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: { + ownerId: DUMMY_ID_2, + dataContractId: DUMMY_ID, + documentTypeName: 'note', + }, + identityKey, + signer, + }); + expect.fail('expected prepareDocumentDelete to reject missing id'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/id/i); + } + }); + + it('rejects a plain object with no ownerId', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: { + id: DUMMY_ID, + dataContractId: DUMMY_ID, + documentTypeName: 'note', + }, + identityKey, + signer, + }); + expect.fail('expected prepareDocumentDelete to reject missing ownerId'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/ownerId/i); + } + }); + + it('rejects a plain object with no dataContractId', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: { + id: DUMMY_ID, + ownerId: DUMMY_ID_2, + documentTypeName: 'note', + }, + identityKey, + signer, + }); + expect.fail('expected prepareDocumentDelete to reject missing dataContractId'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/dataContractId/i); + } + }); + + it('rejects a plain object with no documentTypeName', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: { + id: DUMMY_ID, + ownerId: DUMMY_ID_2, + dataContractId: DUMMY_ID, + }, + identityKey, + signer, + }); + expect.fail('expected prepareDocumentDelete to reject missing documentTypeName'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/documentTypeName/i); + } + }); + + it('rejects a Document instance when identityKey has the wrong shape', async () => { + const { signer } = buildSigner(); + const document = buildDocument({ revision: 1 }); + + try { + await client.prepareDocumentDelete({ + document, + identityKey: {}, + signer, + }); + expect.fail('expected prepareDocumentDelete to reject invalid identityKey'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/identityKey/i); + } + }); + }); + describe('documentCreate()/documentReplace()', () => { it('documentCreate rejects a document with revision 0', async () => { const { signer, identityKey } = buildSigner(); From b24f12162abd9b12fd23ce0c1391e49f9d9776b4 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 9 May 2026 19:10:48 -0500 Subject: [PATCH 11/24] fix(wasm-sdk): reuse document transition builder --- .../platform/documents/transitions/delete.rs | 12 +- packages/rs-sdk/src/platform/transition.rs | 2 +- .../src/platform/transition/put_document.rs | 155 +++++++++----- .../src/platform/transition/validation.rs | 104 ++++++++-- .../src/state_transitions/document.rs | 193 ++++-------------- 5 files changed, 231 insertions(+), 235 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index b55b8aa635c..5a4beb01dc7 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -1,18 +1,18 @@ -use crate::platform::Identifier; use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::Identifier; use crate::{Error, Sdk}; -use dpp::data_contract::DataContract; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; use dpp::document::{Document, INITIAL_REVISION}; -use dpp::identity::IdentityPublicKey; use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; use dpp::prelude::UserFeeIncrease; -use dpp::state_transition::StateTransition; -use dpp::state_transition::batch_transition::BatchTransition; -use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; use dpp::tokens::token_payment_info::TokenPaymentInfo; use dpp::version::PlatformVersion; use std::sync::Arc; diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index b5aa9aa0516..441869f2a59 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -28,7 +28,7 @@ mod txid; #[cfg(feature = "shielded")] pub mod unshield; pub mod update_price_of_document; -pub(crate) mod validation; +pub mod validation; pub mod vote; pub mod waitable; pub mod withdraw_from_identity; diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 80c4c7cb70b..1c6b68264c9 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -46,6 +46,91 @@ pub trait PutDocument>: Waitable { ) -> Result; } +/// Build, sign, and structurally validate a document create-or-replace +/// [`StateTransition`] without broadcasting it. +/// +/// This is the pre-broadcast half of [`PutDocument::put_to_platform`]: it +/// allocates a fresh identity-contract nonce, picks the create-vs-replace +/// branch based on the document's revision, fills in entropy when missing, +/// applies `user_fee_increase` / `token_payment_info` / +/// `state_transition_creation_options` from `settings`, signs the transition, +/// and runs structure validation. The caller decides whether (and how) to +/// broadcast the returned, signed transition. +/// +/// Errors from this function may have already advanced the local nonce cache +/// without a corresponding remote nonce consumption; if the caller cannot +/// safely retry the transition itself, it should call +/// [`Sdk::refresh_identity_nonce`] to resync. +#[allow(clippy::too_many_arguments)] +pub async fn build_signed_document_create_or_replace_transition>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, +) -> Result { + let new_identity_contract_nonce = sdk + .get_identity_contract_nonce( + document.owner_id(), + document_type.data_contract_id(), + true, + settings, + ) + .await?; + + let put_settings = settings.unwrap_or_default(); + let transition = if document + .revision() + .is_some_and(|rev| rev != INITIAL_REVISION) + { + BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type.as_ref(), + identity_public_key, + new_identity_contract_nonce, + put_settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + put_settings.state_transition_creation_options, + ) + .await? + } else { + let (doc, entropy) = document_state_transition_entropy + .map(|entropy| (document.clone(), entropy)) + .unwrap_or_else(|| { + let mut rng = StdRng::from_entropy(); + let mut doc = document.clone(); + let entropy = rng.gen::<[u8; 32]>(); + doc.set_id(Document::generate_document_id_v0( + &document_type.data_contract_id(), + &doc.owner_id(), + document_type.name(), + entropy.as_slice(), + )); + (doc, entropy) + }); + BatchTransition::new_document_creation_transition_from_document( + doc, + document_type.as_ref(), + entropy, + identity_public_key, + new_identity_contract_nonce, + put_settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + put_settings.state_transition_creation_options, + ) + .await? + }; + ensure_valid_state_transition_structure(&transition, sdk.version())?; + Ok(transition) +} + #[async_trait::async_trait] impl> PutDocument for Document { async fn put_to_platform( @@ -58,64 +143,22 @@ impl> PutDocument for Document { signer: &S, settings: Option, ) -> Result { - let new_identity_contract_nonce = sdk - .get_identity_contract_nonce( - self.owner_id(), - document_type.data_contract_id(), - true, - settings, - ) - .await?; - - let settings = settings.unwrap_or_default(); - let transition = if self.revision().is_some() - && self.revision().unwrap() != INITIAL_REVISION - { - BatchTransition::new_document_replacement_transition_from_document( - self.clone(), - document_type.as_ref(), - &identity_public_key, - new_identity_contract_nonce, - settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - settings.state_transition_creation_options, - ) - .await? - } else { - let (document, document_state_transition_entropy) = document_state_transition_entropy - .map(|entropy| (self.clone(), entropy)) - .unwrap_or_else(|| { - let mut rng = StdRng::from_entropy(); - let mut document = self.clone(); - let entropy = rng.gen::<[u8; 32]>(); - document.set_id(Document::generate_document_id_v0( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - entropy.as_slice(), - )); - (document, entropy) - }); - BatchTransition::new_document_creation_transition_from_document( - document, - document_type.as_ref(), - document_state_transition_entropy, - &identity_public_key, - new_identity_contract_nonce, - settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - settings.state_transition_creation_options, - ) - .await? - }; - ensure_valid_state_transition_structure(&transition, sdk.version())?; + let transition = build_signed_document_create_or_replace_transition( + sdk, + self, + &document_type, + document_state_transition_entropy, + &identity_public_key, + token_payment_info, + signer, + settings, + ) + .await?; // response is empty for a broadcast, result comes from the stream wait for state transition result - transition.broadcast(sdk, Some(settings)).await?; + transition + .broadcast(sdk, Some(settings.unwrap_or_default())) + .await?; Ok(transition) } diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index 846d9ddae2d..7fa7f911f50 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -2,6 +2,7 @@ use crate::Error; use dpp::{ consensus::{basic::BasicError, ConsensusError}, state_transition::{StateTransition, StateTransitionStructureValidation}, + validation::SimpleConsensusValidationResult, version::PlatformVersion, }; @@ -13,30 +14,99 @@ fn is_unsupported_feature_error(error: &ConsensusError) -> bool { ) } +/// Filter `UnsupportedFeatureError` entries out of a structure-validation +/// result and convert any remaining errors into [`Error`]. +/// +/// `UnsupportedFeatureError` only signals that structure validation is not yet +/// implemented for that state transition kind, so it must never mask a real +/// validation failure. If the result becomes empty after filtering we treat it +/// as a no-op pass. +fn map_validation_result(result: SimpleConsensusValidationResult) -> Result<(), Error> { + if result.is_valid() { + return Ok(()); + } + + let real_errors: Vec = result + .errors + .into_iter() + .filter(|e| !is_unsupported_feature_error(e)) + .collect(); + + if real_errors.is_empty() { + Ok(()) + } else { + Err(SimpleConsensusValidationResult::new_with_errors(real_errors).into()) + } +} + /// Ensures a state transition passes structure validation before broadcasting. /// -/// Note: UnsupportedFeatureError is allowed to pass through, as it indicates +/// 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( +/// +/// When the result mixes `UnsupportedFeatureError`s with other errors, the +/// unsupported variants are filtered out so the surfaced error is always a +/// real validation problem rather than a "not implemented" placeholder. +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()) - } + map_validation_result(state_transition.validate_structure(platform_version)) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::consensus::basic::unsupported_feature_error::UnsupportedFeatureError; + use dpp::consensus::basic::value_error::ValueError; + + fn unsupported_error() -> ConsensusError { + ConsensusError::BasicError(BasicError::UnsupportedFeatureError( + UnsupportedFeatureError::new("feature".to_string(), 0), + )) + } + + fn value_error(msg: &str) -> ConsensusError { + ConsensusError::BasicError(BasicError::ValueError(ValueError::new_from_string( + msg.to_string(), + ))) + } + + /// When every error is an UnsupportedFeatureError we treat the validation + /// result as a no-op and return Ok. + #[test] + fn all_unsupported_errors_are_treated_as_ok() { + let result = SimpleConsensusValidationResult::new_with_errors(vec![ + unsupported_error(), + unsupported_error(), + ]); + assert!(map_validation_result(result).is_ok()); + } + + /// A single non-UnsupportedFeature error is surfaced as a real failure. + #[test] + fn single_real_error_is_surfaced() { + let result = + SimpleConsensusValidationResult::new_with_errors(vec![value_error("bad value")]); + let err = map_validation_result(result).expect_err("expected real error"); + assert!(format!("{err}").contains("bad value")); + } + + /// When errors mix unsupported with real ones, the real one wins — + /// without this filtering, the first-error conversion could surface the + /// UnsupportedFeatureError and incorrectly let the transition through. + #[test] + fn mixed_errors_skip_unsupported_and_return_real_error() { + let result = SimpleConsensusValidationResult::new_with_errors(vec![ + unsupported_error(), + value_error("real failure"), + ]); + let err = map_validation_result(result).expect_err("expected real error"); + assert!( + format!("{err}").contains("real failure"), + "expected real-failure message, got: {err}" + ); } } diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index e1282f26638..38eb55b4906 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -34,23 +34,21 @@ use crate::error::WasmSdkError; use crate::sdk::WasmSdk; use crate::settings::PutSettingsInput; -use dash_sdk::dpp::dashcore::secp256k1::rand::rngs::StdRng; -use dash_sdk::dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; -use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentType; -use dash_sdk::dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; +use dash_sdk::dpp::document::{Document, DocumentV0Getters, INITIAL_REVISION}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; -use dash_sdk::dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; -use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; -use dash_sdk::platform::transition::put_document::PutDocument; +use dash_sdk::platform::transition::put_document::{ + build_signed_document_create_or_replace_transition, PutDocument, +}; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; +use dash_sdk::platform::transition::validation::ensure_valid_state_transition_structure; use js_sys::Reflect; use std::sync::Arc; use wasm_bindgen::{prelude::*, JsCast}; @@ -622,7 +620,9 @@ impl WasmSdk { try_from_options_optional::(&options, "settings")?.map(Into::into); let token_payment_info = try_from_options_optional_token_payment_info(&options)?; - // Build and sign the state transition without broadcasting + // Build, sign, and structurally validate the state transition without + // broadcasting it. Errors here refresh the identity-contract nonce so + // it doesn't drift past Platform's view (the helper does that for us). let state_transition = build_document_create_or_replace_transition( &document, &document_type, @@ -635,19 +635,6 @@ impl WasmSdk { ) .await?; - // Validate structure before handing the ST back, mirroring rs-sdk's - // pre-broadcast check. For document Batch transitions this currently - // ends up as a no-op because DPP returns UnsupportedFeatureError until - // that structure validation is implemented there. - if let Err(err) = - validate_state_transition_structure(&state_transition, self.inner_sdk().version()) - { - self.inner_sdk() - .refresh_identity_nonce(&document.owner_id()) - .await; - return Err(err); - } - Ok(state_transition.into()) } } @@ -733,7 +720,9 @@ impl WasmSdk { try_from_options_optional::(&options, "settings")?.map(Into::into); let token_payment_info = try_from_options_optional_token_payment_info(&options)?; - // Build and sign the state transition without broadcasting + // Build, sign, and structurally validate the state transition without + // broadcasting it. Errors here refresh the identity-contract nonce so + // it doesn't drift past Platform's view (the helper does that for us). let state_transition = build_document_create_or_replace_transition( &document, &document_type, @@ -746,19 +735,6 @@ impl WasmSdk { ) .await?; - // Validate structure before handing the ST back, mirroring rs-sdk's - // pre-broadcast check. For document Batch transitions this currently - // ends up as a no-op because DPP returns UnsupportedFeatureError until - // that structure validation is implemented there. - if let Err(err) = - validate_state_transition_structure(&state_transition, self.inner_sdk().version()) - { - self.inner_sdk() - .refresh_identity_nonce(&document.owner_id()) - .await; - return Err(err); - } - Ok(state_transition.into()) } } @@ -915,10 +891,10 @@ impl WasmSdk { // ends up as a no-op because DPP returns UnsupportedFeatureError until // that structure validation is implemented there. if let Err(err) = - validate_state_transition_structure(&state_transition, self.inner_sdk().version()) + ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version()) { self.inner_sdk().refresh_identity_nonce(&owner_id).await; - return Err(err); + return Err(err.into()); } Ok(state_transition.into()) @@ -1294,18 +1270,14 @@ impl WasmSdk { // Helper Functions // ============================================================================ -/// Build and sign a document create or replace state transition without broadcasting. +/// Build, sign and validate a document create-or-replace state transition +/// without broadcasting it. /// -/// This replicates the ST construction logic from `PutDocument::put_to_platform` in `rs-sdk`, -/// but stops before the broadcast step. The returned `StateTransition` is fully signed and -/// ready to be broadcast via `broadcastStateTransition()`. -/// -/// Whether this produces a create or replace transition depends on the document's revision: -/// - If revision is `None` or `INITIAL_REVISION` → create transition -/// - Otherwise → replace transition -/// -/// Any error after bumping the identity-contract nonce refreshes the nonce cache, -/// mirroring rs-sdk's refresh-on-broadcast-failure idea for this prepare-path failure. +/// This is a thin wrapper around rs-sdk's +/// [`build_signed_document_create_or_replace_transition`], adding wasm-sdk's +/// prepare-path policy: any error after the identity-contract nonce has been +/// allocated (build/sign or structure validation) refreshes the nonce cache so +/// future calls don't drift past Platform's view. #[allow(clippy::too_many_arguments)] async fn build_document_create_or_replace_transition( document: &Document, @@ -1317,73 +1289,26 @@ async fn build_document_create_or_replace_transition( sdk: &dash_sdk::Sdk, settings: Option, ) -> Result { - let new_identity_contract_nonce = sdk - .get_identity_contract_nonce( - document.owner_id(), - document_type.data_contract_id(), - true, - settings, - ) - .await?; let owner_id = document.owner_id(); - let put_settings = settings.unwrap_or_default(); - - let transition_result = if document - .revision() - .is_some_and(|rev| rev != INITIAL_REVISION) + match build_signed_document_create_or_replace_transition( + sdk, + document, + document_type, + document_state_transition_entropy, + identity_public_key, + token_payment_info, + signer, + settings, + ) + .await { - BatchTransition::new_document_replacement_transition_from_document( - document.clone(), - document_type.as_ref(), - identity_public_key, - new_identity_contract_nonce, - put_settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - put_settings.state_transition_creation_options, - ) - .await - } else { - let (doc, entropy) = document_state_transition_entropy - .map(|entropy| (document.clone(), entropy)) - .unwrap_or_else(|| { - let mut rng = StdRng::from_entropy(); - let mut doc = document.clone(); - let entropy = rng.gen::<[u8; 32]>(); - doc.set_id(Document::generate_document_id_v0( - &document_type.data_contract_id(), - &doc.owner_id(), - document_type.name(), - entropy.as_slice(), - )); - (doc, entropy) - }); - BatchTransition::new_document_creation_transition_from_document( - doc, - document_type.as_ref(), - entropy, - identity_public_key, - new_identity_contract_nonce, - put_settings.user_fee_increase.unwrap_or_default(), - token_payment_info, - signer, - sdk.version(), - put_settings.state_transition_creation_options, - ) - .await - }; - - let transition = match transition_result { - Ok(transition) => transition, + Ok(transition) => Ok(transition), Err(err) => { sdk.refresh_identity_nonce(&owner_id).await; - return Err(err.into()); + Err(err.into()) } - }; - - Ok(transition) + } } fn ensure_document_create_revision( @@ -1438,49 +1363,6 @@ fn get_document_type( }) } -/// Validate the structure of a state transition, mirroring the same -/// pre-broadcast check rs-sdk performs. -/// -/// rs-sdk's equivalent helper (`ensure_valid_state_transition_structure`) is -/// crate-private, so we reimplement the same logic against the public -/// `StateTransitionStructureValidation` trait. -/// -/// `UnsupportedFeatureError` is allowed through, matching rs-sdk behavior — -/// DPP currently returns it for transition kinds whose structure validation is -/// not implemented yet. That means this helper is currently a no-op for -/// document Batch transitions used by `prepareDocumentCreate/Replace/Delete` -/// until DPP adds that validation; platform still validates them during execution. -fn validate_state_transition_structure( - state_transition: &dash_sdk::dpp::state_transition::StateTransition, - platform_version: &dash_sdk::dpp::version::PlatformVersion, -) -> Result<(), WasmSdkError> { - use dash_sdk::dpp::consensus::basic::BasicError; - use dash_sdk::dpp::consensus::ConsensusError; - use dash_sdk::dpp::state_transition::StateTransitionStructureValidation; - - let validation_result = state_transition.validate_structure(platform_version); - if validation_result.is_valid() { - return Ok(()); - } - - let all_unsupported = validation_result.errors.iter().all(|e| { - matches!( - e, - ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) - ) - }); - if all_unsupported { - return Ok(()); - } - - // Surface the first error through the ProtocolError → WasmSdkError conversion chain. - // `is_valid()` returned false, so `errors` is non-empty; fall back defensively. - let first = validation_result.errors.into_iter().next().ok_or_else(|| { - WasmSdkError::generic("state transition structure validation failed without an error") - })?; - Err(dash_sdk::dpp::ProtocolError::from(first).into()) -} - #[cfg(test)] mod tests { use super::*; @@ -1532,8 +1414,9 @@ mod tests { /// DPP's `validate_structure` implementation returns `UnsupportedFeatureError` /// for identity-based state transitions (see rs-dpp `state_transition/mod.rs` /// `StateTransitionStructureValidation` impl). rs-sdk intentionally allows - /// these through before broadcasting; we must do the same so we don't - /// over-reject STs in the prepare paths. + /// these through before broadcasting; the prepare APIs delegate to that + /// shared helper, so we sanity-check the same behavior here against the + /// public API to guard against regressions if the helper relocates. #[test] fn validate_accepts_unsupported_feature_errors() { let version = PlatformVersion::latest(); @@ -1541,7 +1424,7 @@ mod tests { .expect("default versioned ICT transition") .into(); assert!( - validate_state_transition_structure(&st, version).is_ok(), + ensure_valid_state_transition_structure(&st, version).is_ok(), "identity-based STs should pass through via UnsupportedFeatureError" ); } From fa0706e11b328e2c7ea45a6b05a0a7c4c813dfd1 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 10 May 2026 04:30:36 -0500 Subject: [PATCH 12/24] fix(wasm-sdk): address document prepare review follow-ups --- .../platform/documents/transitions/delete.rs | 3 + .../src/platform/transition/put_document.rs | 154 ++++++++++++++++-- packages/wasm-sdk/README.md | 6 + .../src/state_transitions/document.rs | 25 ++- .../functional/transitions/documents.spec.ts | 8 +- 5 files changed, 176 insertions(+), 20 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 5a4beb01dc7..2e7a4eea045 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -1,5 +1,6 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::ensure_valid_state_transition_structure; use crate::platform::Identifier; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -352,6 +353,8 @@ impl Sdk { .sign(self, signing_key, signer, platform_version) .await?; + ensure_valid_state_transition_structure(&state_transition, platform_version)?; + let proof_result = state_transition .broadcast_and_wait::(self, put_settings) .await?; diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 1c6b68264c9..853a164d988 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -15,6 +15,31 @@ use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::StateTransition; use dpp::tokens::token_payment_info::TokenPaymentInfo; +fn is_document_replace_revision(revision: Option) -> bool { + revision.is_some_and(|rev| rev != INITIAL_REVISION) +} + +fn resolve_document_create_entropy( + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, +) -> (Document, [u8; 32]) { + document_state_transition_entropy + .map(|entropy| (document.clone(), entropy)) + .unwrap_or_else(|| { + let mut rng = StdRng::from_entropy(); + let mut doc = document.clone(); + let entropy = rng.gen::<[u8; 32]>(); + doc.set_id(Document::generate_document_id_v0( + &document_type.data_contract_id(), + &doc.owner_id(), + document_type.name(), + entropy.as_slice(), + )); + (doc, entropy) + }) +} + #[async_trait::async_trait] /// A trait for putting a document to platform pub trait PutDocument>: Waitable { @@ -82,10 +107,7 @@ pub async fn build_signed_document_create_or_replace_transition(); - doc.set_id(Document::generate_document_id_v0( - &document_type.data_contract_id(), - &doc.owner_id(), - document_type.name(), - entropy.as_slice(), - )); - (doc, entropy) - }); + let (doc, entropy) = resolve_document_create_entropy( + document, + document_type, + document_state_transition_entropy, + ); BatchTransition::new_document_creation_transition_from_document( doc, document_type.as_ref(), @@ -187,3 +200,108 @@ impl> PutDocument for Document { Self::wait_for_response(sdk, state_transition, settings).await } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::config::DataContractConfig; + use dpp::document::DocumentV0; + use dpp::platform_value::Value; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + use serde_json::json; + use std::collections::BTreeMap; + + fn test_document_type() -> DocumentType { + let platform_version = PlatformVersion::latest(); + let schema = serde_json::from_value::(json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0 + } + }, + "additionalProperties": false, + })) + .expect("document schema"); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("default data contract config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect("document type") + } + + fn test_document(revision: Option, id: Identifier) -> Document { + Document::V0(DocumentV0 { + id, + owner_id: Identifier::from([7; 32]), + properties: Default::default(), + revision, + 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, + }) + } + + #[test] + fn branch_selection_uses_revision_rules() { + assert!(!is_document_replace_revision(None)); + assert!(!is_document_replace_revision(Some(INITIAL_REVISION))); + assert!(is_document_replace_revision(Some(INITIAL_REVISION + 1))); + } + + #[test] + fn creation_entropy_fallback_regenerates_document_id() { + let document_type = test_document_type(); + let original_id = Identifier::from([3; 32]); + let document = test_document(None, original_id); + + let (resolved_document, entropy) = + resolve_document_create_entropy(&document, &document_type, None); + + let expected_id = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + entropy.as_slice(), + ); + + assert_eq!(resolved_document.id(), expected_id); + assert_ne!(resolved_document.id(), original_id); + } + + #[test] + fn provided_entropy_preserves_existing_document_id() { + let document_type = test_document_type(); + let original_id = Identifier::from([9; 32]); + let document = test_document(Some(INITIAL_REVISION), original_id); + let provided_entropy = [11; 32]; + + let (resolved_document, resolved_entropy) = + resolve_document_create_entropy(&document, &document_type, Some(provided_entropy)); + + assert_eq!(resolved_entropy, provided_entropy); + assert_eq!(resolved_document.id(), original_id); + } +} diff --git a/packages/wasm-sdk/README.md b/packages/wasm-sdk/README.md index 65451e43d98..8e011802396 100644 --- a/packages/wasm-sdk/README.md +++ b/packages/wasm-sdk/README.md @@ -175,6 +175,12 @@ client.free(); The full surface includes identity, document, contract, token, group, epoch/system, and proof helpers. +### Document revision rules + +- `documentCreate()` requires the document revision to be omitted or set to `1` (`INITIAL_REVISION`). +- `documentReplace()` requires the document revision to be greater than `1`. +- Invalid one-shot document revisions now reject with `InvalidArgument`; the wasm SDK no longer silently routes them to the other transition type. + ### Logging By default, the SDK is silent. You can enable tracing logs globally or via the builder: diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 38eb55b4906..80205527b04 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -30,6 +30,16 @@ //! refreshed, but it does not reserve or consume the nonce remotely on Platform. If //! you need a "dry run" with no local nonce-cache side effects in this SDK instance, //! do not use the prepare API. +//! +//! ## One-shot document revision rules +//! +//! `documentCreate()` now accepts only documents whose revision is unset or +//! `INITIAL_REVISION`, and `documentReplace()` now accepts only documents whose +//! revision is greater than `INITIAL_REVISION`. +//! +//! Earlier wasm-sdk behavior could silently route invalid revisions to the other +//! transition type. That implicit routing is no longer performed: invalid +//! revisions now fail with `InvalidArgument` instead. use crate::error::WasmSdkError; use crate::sdk::WasmSdk; @@ -137,6 +147,9 @@ export interface DocumentCreateOptions { * The document to create. * Use `new Document(...)` or `Document.fromJSON(...)` to construct it. * Must include dataContractId, documentTypeName, ownerId, and entropy. + * Revision must be omitted or set to 1 (INITIAL_REVISION). + * Other revisions are rejected with InvalidArgument instead of being routed + * to documentReplace(). */ document: Document; @@ -181,6 +194,10 @@ impl WasmSdk { /// 3. Creates and signs the document create transition /// 4. Broadcasts and waits for confirmation /// + /// The document revision must be unset or `INITIAL_REVISION`. Revisions + /// greater than `INITIAL_REVISION` now return `InvalidArgument` instead of + /// being routed to `documentReplace()`. + /// /// @param options - Creation options including document, identity key, and signer /// @returns Promise that resolves when the document is created #[wasm_bindgen(js_name = "documentCreate")] @@ -261,7 +278,9 @@ export interface DocumentReplaceOptions { /** * The document with updated data. * Must have the same ID as the existing document. - * Revision should be set to current revision + 1. + * Revision must be set to current revision + 1 and therefore be greater than + * 1 (INITIAL_REVISION). Missing, 0, or 1 revisions are rejected with + * InvalidArgument instead of being routed to documentCreate(). */ document: Document; @@ -306,6 +325,10 @@ impl WasmSdk { /// 3. Creates and signs the document replace transition /// 4. Broadcasts and waits for confirmation /// + /// The document revision must be greater than `INITIAL_REVISION`. Missing + /// or initial revisions now return `InvalidArgument` instead of being + /// routed to `documentCreate()`. + /// /// @param options - Replace options including document, identity key, and signer /// @returns Promise that resolves when the document is replaced #[wasm_bindgen(js_name = "documentReplace")] diff --git a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts index da78f8e2c97..9706a0246f8 100644 --- a/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts +++ b/packages/wasm-sdk/tests/functional/transitions/documents.spec.ts @@ -378,13 +378,19 @@ describe('Document State Transitions', function describeDocumentStateTransitions await client.broadcastStateTransition(restored); await client.waitForResponse(restored); + let secondBroadcastError; try { await client.broadcastStateTransition(restored); await client.waitForResponse(restored); } catch (e) { - expect(String(e?.message ?? e)).to.match(/already|duplicate|exists|known|cache/i); + secondBroadcastError = e; } + expect(secondBroadcastError, 'expected second broadcast to be rejected').to.exist(); + expect(String(secondBroadcastError?.message ?? secondBroadcastError)).to.match( + /\b(duplicate|already known|already exists|existing state transition|known state transition)\b/i, + ); + await waitForPlatform(); } From 97953ab6105eee71947911cabe91b32ecaec5e30 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 10 May 2026 05:51:51 -0500 Subject: [PATCH 13/24] fix(rs-sdk): roll back document prepare nonces --- packages/rs-sdk/src/internal_cache/mod.rs | 200 ++++++++++++++ .../platform/documents/transitions/delete.rs | 35 ++- .../src/platform/transition/put_document.rs | 257 +++++++++++++++++- packages/rs-sdk/src/sdk.rs | 34 +++ .../src/state_transitions/document.rs | 97 +++---- 5 files changed, 557 insertions(+), 66 deletions(-) diff --git a/packages/rs-sdk/src/internal_cache/mod.rs b/packages/rs-sdk/src/internal_cache/mod.rs index 8b712fb8920..6aa7cdeb60e 100644 --- a/packages/rs-sdk/src/internal_cache/mod.rs +++ b/packages/rs-sdk/src/internal_cache/mod.rs @@ -245,6 +245,60 @@ impl NonceCache { } } + /// Conditionally roll back a previously-bumped identity-contract nonce + /// after a **local** (pre-broadcast) failure. + /// + /// Use this only when the caller is certain the nonce was never observed + /// by the network — e.g. when build/sign or local structure validation + /// fails after [`get_identity_contract_nonce`](Self::get_identity_contract_nonce) + /// returned `allocated_nonce` with `bump_first = true`. Broadcast failures + /// must keep using [`refresh`](Self::refresh) instead, because the network + /// may have already accepted the nonce. + /// + /// The rollback is conditional: it only adjusts the cache entry if + /// `current_nonce` still equals `allocated_nonce`. If a concurrent caller + /// has already bumped past it, the entry is left untouched so concurrent + /// allocations are not clobbered. A missing entry is also left alone. + /// + /// We use [`LruCache::peek_mut`] so the rollback does not promote the + /// entry to most-recently-used: a rollback signals the entry is *not* in + /// active use, the opposite of what [`refresh`](Self::refresh) signals. + pub(crate) async fn rollback_identity_contract_nonce( + &self, + identity_id: Identifier, + contract_id: Identifier, + allocated_nonce: IdentityNonce, + ) { + if allocated_nonce == 0 { + // Nothing to roll back; bumping never produces 0 (it starts at 1). + return; + } + let key = IdentityContractPair { + identity_id, + contract_id, + }; + let mut guard = self.contract_nonces.lock().await; + if let Some(entry) = guard.peek_mut(&key) { + if entry.current_nonce == allocated_nonce { + entry.current_nonce = allocated_nonce - 1; + tracing::trace!( + identity_id = %identity_id, + contract_id = %contract_id, + allocated_nonce, + "rolled back identity-contract nonce after local pre-broadcast failure" + ); + } else { + tracing::trace!( + identity_id = %identity_id, + contract_id = %contract_id, + allocated_nonce, + cached_nonce = entry.current_nonce, + "skipped identity-contract nonce rollback: cache moved past allocated nonce" + ); + } + } + } + /// Shared nonce cache logic. Checks staleness and drift, fetches from /// Platform when needed, and maintains the cache entry. /// @@ -999,4 +1053,150 @@ mod nonce_cache_tests { .unwrap(); assert_eq!(nonce, 6); } + + // --- rollback_identity_contract_nonce: pre-broadcast rollback semantics --- + #[tokio::test] + async fn rollback_decrements_when_cache_matches_allocated_nonce() { + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let mut sdk = crate::Sdk::new_mock(); + let identity_id = Identifier::default(); + let contract_id = Identifier::from([1u8; 32]); + let settings = PutSettings::default(); + + sdk.mock() + .expect_fetch::( + (identity_id, contract_id), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set mock expectation"); + + // Allocate: platform=10 → bump to 11. + let allocated = sdk + .get_identity_contract_nonce(identity_id, contract_id, true, Some(settings)) + .await + .unwrap(); + assert_eq!(allocated, 11); + + // Local (pre-broadcast) failure: rollback to 10. + sdk.rollback_identity_contract_nonce(identity_id, contract_id, allocated) + .await; + + // Next allocation should bump from 10, producing 11 again. + let next = sdk + .get_identity_contract_nonce(identity_id, contract_id, true, Some(settings)) + .await + .unwrap(); + assert_eq!( + next, 11, + "rollback should free the allocated nonce for reuse" + ); + } + + #[tokio::test] + async fn rollback_is_noop_when_cache_advanced_past_allocated_nonce() { + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let mut sdk = crate::Sdk::new_mock(); + let identity_id = Identifier::default(); + let contract_id = Identifier::from([2u8; 32]); + let settings = PutSettings::default(); + + sdk.mock() + .expect_fetch::( + (identity_id, contract_id), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set mock expectation"); + + // Allocate twice: 11 then 12 (both from cache, second one cache-only bump). + let first = sdk + .get_identity_contract_nonce(identity_id, contract_id, true, Some(settings)) + .await + .unwrap(); + assert_eq!(first, 11); + let second = sdk + .get_identity_contract_nonce(identity_id, contract_id, true, Some(settings)) + .await + .unwrap(); + assert_eq!(second, 12); + + // Roll back the FIRST allocation. The cache has already moved past 11 + // (it is now 12), so the rollback must be a no-op to avoid clobbering + // the second allocation. + sdk.rollback_identity_contract_nonce(identity_id, contract_id, first) + .await; + + // Next allocation should bump from 12 to 13, not from 10. + let third = sdk + .get_identity_contract_nonce(identity_id, contract_id, true, Some(settings)) + .await + .unwrap(); + assert_eq!(third, 13, "rollback must not clobber a newer allocation"); + } + + #[tokio::test] + async fn rollback_is_noop_when_entry_missing() { + let mut sdk = crate::Sdk::new_mock(); + let identity_id = Identifier::default(); + let contract_id = Identifier::from([3u8; 32]); + let _ = &mut sdk; + + // Should not panic / not insert anything. + sdk.rollback_identity_contract_nonce(identity_id, contract_id, 5) + .await; + } + + #[tokio::test] + async fn rollback_does_not_affect_other_contract_entries() { + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let mut sdk = crate::Sdk::new_mock(); + let identity_id = Identifier::default(); + let contract_a = Identifier::from([4u8; 32]); + let contract_b = Identifier::from([5u8; 32]); + let settings = PutSettings::default(); + + sdk.mock() + .expect_fetch::( + (identity_id, contract_a), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set mock A"); + sdk.mock() + .expect_fetch::( + (identity_id, contract_b), + Some(IdentityContractNonceFetcher(20u64)), + ) + .await + .expect("set mock B"); + + let allocated_a = sdk + .get_identity_contract_nonce(identity_id, contract_a, true, Some(settings)) + .await + .unwrap(); + let allocated_b = sdk + .get_identity_contract_nonce(identity_id, contract_b, true, Some(settings)) + .await + .unwrap(); + assert_eq!(allocated_a, 11); + assert_eq!(allocated_b, 21); + + // Roll back A only. + sdk.rollback_identity_contract_nonce(identity_id, contract_a, allocated_a) + .await; + + // B must be unaffected. + let next_b = sdk + .get_identity_contract_nonce(identity_id, contract_b, true, Some(settings)) + .await + .unwrap(); + assert_eq!( + next_b, 22, + "contract B nonce must continue from its own bump" + ); + } } diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 2e7a4eea045..b0b36a51f7c 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -8,6 +8,7 @@ use dpp::data_contract::DataContract; use dpp::document::{Document, INITIAL_REVISION}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; +use dpp::prelude::IdentityNonce; use dpp::prelude::UserFeeIncrease; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; @@ -152,7 +153,13 @@ impl DocumentDeleteTransitionBuilder { self } - /// Signs the document delete transition + /// Signs the document delete transition. + /// + /// Allocates a fresh identity-contract nonce from `sdk` and delegates to + /// [`Self::sign_with_nonce`]. Use [`Self::sign_with_nonce`] directly if + /// you need to pre-allocate the nonce so a pre-broadcast failure can roll + /// it back via + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce). /// /// # Arguments /// @@ -180,6 +187,32 @@ impl DocumentDeleteTransitionBuilder { ) .await?; + self.sign_with_nonce( + identity_contract_nonce, + identity_public_key, + signer, + platform_version, + ) + .await + } + + /// Signs the document delete transition using a pre-allocated + /// identity-contract nonce. + /// + /// This variant lets the caller separate nonce allocation from signing so + /// pre-broadcast failures can be rolled back by calling + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) + /// with the same `identity_contract_nonce`. The caller is responsible for + /// having obtained the nonce via + /// [`Sdk::get_identity_contract_nonce`](crate::Sdk::get_identity_contract_nonce) + /// with `bump_first = true` for the same `(owner_id, contract_id)` pair. + pub async fn sign_with_nonce( + &self, + identity_contract_nonce: IdentityNonce, + identity_public_key: &IdentityPublicKey, + signer: &impl Signer, + platform_version: &PlatformVersion, + ) -> Result { let document_type = self .data_contract .document_type_for_name(&self.document_type_name) diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 853a164d988..d1bfcdce52f 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -19,6 +19,56 @@ fn is_document_replace_revision(revision: Option) -> bool { revision.is_some_and(|rev| rev != INITIAL_REVISION) } +/// Reject documents whose revision is `Some(0)` for the create-or-replace +/// dispatch helper. Both create and replace require non-zero revisions, so +/// `0` is always invalid regardless of caller intent. +fn ensure_revision_nonzero(revision: Option) -> Result<(), Error> { + if matches!(revision, Some(0)) { + return Err(Error::Generic( + "InvalidArgument: document revision 0 is invalid; \ + use unset or 1 (INITIAL_REVISION) for create, or > 1 for replace" + .to_string(), + )); + } + Ok(()) +} + +/// Strict revision guard for the document **create** path. +/// +/// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any +/// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side +/// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard. +fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { + match revision { + None => Ok(()), + Some(rev) if rev == INITIAL_REVISION => Ok(()), + Some(rev) => Err(Error::Generic(format!( + "InvalidArgument: document revision is {rev} but create requires revision \ + to be unset or {INITIAL_REVISION}; use the replace path for revisions > {INITIAL_REVISION}" + ))), + } +} + +/// Strict revision guard for the document **replace** path. +/// +/// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`, +/// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast +/// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard. +fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { + match revision { + Some(rev) if rev > INITIAL_REVISION => Ok(()), + Some(rev) => Err(Error::Generic(format!( + "InvalidArgument: document revision is {rev} but replace requires revision > \ + {INITIAL_REVISION}; use the create path for new documents" + ))), + None => Err(Error::Generic( + "InvalidArgument: document must have a revision set for replace; \ + use the create path for new documents" + .to_string(), + )), + } +} + fn resolve_document_create_entropy( document: &Document, document_type: &DocumentType, @@ -82,10 +132,24 @@ pub trait PutDocument>: Waitable { /// and runs structure validation. The caller decides whether (and how) to /// broadcast the returned, signed transition. /// -/// Errors from this function may have already advanced the local nonce cache -/// without a corresponding remote nonce consumption; if the caller cannot -/// safely retry the transition itself, it should call -/// [`Sdk::refresh_identity_nonce`] to resync. +/// # Revision validation +/// +/// The dispatch is driven by the document revision and rejects only the +/// always-invalid `Some(0)` case. Use +/// [`build_signed_document_create_transition`] or +/// [`build_signed_document_replace_transition`] for fail-fast validation that +/// also enforces caller intent (mismatched create/replace revisions error +/// before any nonce allocation). +/// +/// # Nonce handling on local errors +/// +/// On any **pre-broadcast** failure (build, sign, or local structure +/// validation) this helper conditionally rolls back the bumped +/// identity-contract nonce via +/// [`Sdk::rollback_identity_contract_nonce`], so the local cache does not +/// advance past a nonce the network never observed. The rollback only adjusts +/// the cache entry if it still equals the nonce allocated by this attempt, so +/// concurrent allocations are not clobbered. #[allow(clippy::too_many_arguments)] pub async fn build_signed_document_create_or_replace_transition>( sdk: &Sdk, @@ -97,15 +161,134 @@ pub async fn build_signed_document_create_or_replace_transition, ) -> Result { + // Reject the always-invalid `Some(0)` revision before allocating any + // nonce. Strict create/replace intent validation is the job of the + // dedicated helpers below. + ensure_revision_nonzero(document.revision())?; + + let owner_id = document.owner_id(); + let contract_id = document_type.data_contract_id(); let new_identity_contract_nonce = sdk - .get_identity_contract_nonce( - document.owner_id(), - document_type.data_contract_id(), - true, - settings, - ) + .get_identity_contract_nonce(owner_id, contract_id, true, settings) .await?; + let result = build_and_sign_create_or_replace_after_nonce( + sdk, + document, + document_type, + document_state_transition_entropy, + identity_public_key, + token_payment_info, + signer, + settings, + new_identity_contract_nonce, + ) + .await; + + match result { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce( + owner_id, + contract_id, + new_identity_contract_nonce, + ) + .await; + Err(err) + } + } +} + +/// Build, sign, and structurally validate a document **create** transition +/// without broadcasting it. +/// +/// This is a fail-fast wrapper around +/// [`build_signed_document_create_or_replace_transition`] that enforces the +/// create-path revision boundary before any nonce allocation: the document +/// revision must be unset or equal to [`INITIAL_REVISION`]. Any other value +/// (including `Some(0)` and revisions greater than `INITIAL_REVISION`) is +/// rejected here, mirroring the wasm-sdk's `prepareDocumentCreate` guard so +/// native callers get the same precise behavior. +/// +/// See [`build_signed_document_create_or_replace_transition`] for the local +/// nonce-rollback semantics on build/sign/validation failures. +#[allow(clippy::too_many_arguments)] +pub async fn build_signed_document_create_transition>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, +) -> Result { + ensure_revision_for_create(document.revision())?; + build_signed_document_create_or_replace_transition( + sdk, + document, + document_type, + document_state_transition_entropy, + identity_public_key, + token_payment_info, + signer, + settings, + ) + .await +} + +/// Build, sign, and structurally validate a document **replace** transition +/// without broadcasting it. +/// +/// This is a fail-fast wrapper around +/// [`build_signed_document_create_or_replace_transition`] that enforces the +/// replace-path revision boundary before any nonce allocation: the document +/// revision must be greater than [`INITIAL_REVISION`]. `None`, `Some(0)`, +/// and `Some(INITIAL_REVISION)` are rejected here, mirroring the wasm-sdk's +/// `prepareDocumentReplace` guard so native callers get the same precise +/// behavior. +/// +/// See [`build_signed_document_create_or_replace_transition`] for the local +/// nonce-rollback semantics on build/sign/validation failures. +#[allow(clippy::too_many_arguments)] +pub async fn build_signed_document_replace_transition>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, +) -> Result { + ensure_revision_for_replace(document.revision())?; + build_signed_document_create_or_replace_transition( + sdk, + document, + document_type, + None, // entropy is unused on the replace path + identity_public_key, + token_payment_info, + signer, + settings, + ) + .await +} + +/// Inner build/sign/validation step shared by the create-or-replace dispatch. +/// Runs after the identity-contract nonce has been allocated; the caller is +/// responsible for rolling that nonce back if this returns an error. +#[allow(clippy::too_many_arguments)] +async fn build_and_sign_create_or_replace_after_nonce>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, + new_identity_contract_nonce: u64, +) -> Result { let put_settings = settings.unwrap_or_default(); let transition = if is_document_replace_revision(document.revision()) { BatchTransition::new_document_replacement_transition_from_document( @@ -271,6 +454,60 @@ mod tests { assert!(is_document_replace_revision(Some(INITIAL_REVISION + 1))); } + #[test] + fn ensure_revision_nonzero_rejects_only_zero() { + assert!(ensure_revision_nonzero(None).is_ok()); + assert!(ensure_revision_nonzero(Some(INITIAL_REVISION)).is_ok()); + assert!(ensure_revision_nonzero(Some(INITIAL_REVISION + 1)).is_ok()); + assert!(ensure_revision_nonzero(Some(u64::MAX)).is_ok()); + + let err = ensure_revision_nonzero(Some(0)).expect_err("revision 0 must error"); + let msg = err.to_string(); + assert!(msg.contains("InvalidArgument"), "msg: {msg}"); + assert!(msg.contains("revision 0"), "msg: {msg}"); + } + + #[test] + fn ensure_revision_for_create_accepts_none_and_initial_revision() { + assert!(ensure_revision_for_create(None).is_ok()); + assert!(ensure_revision_for_create(Some(INITIAL_REVISION)).is_ok()); + } + + #[test] + fn ensure_revision_for_create_rejects_zero_and_above_initial() { + let zero = ensure_revision_for_create(Some(0)).expect_err("revision 0 must error"); + assert!(zero.to_string().contains("InvalidArgument")); + assert!(zero.to_string().contains("create requires revision")); + + let above = ensure_revision_for_create(Some(INITIAL_REVISION + 1)) + .expect_err("revision > INITIAL_REVISION must error on create path"); + assert!(above.to_string().contains("InvalidArgument")); + assert!(above.to_string().contains("replace path")); + } + + #[test] + fn ensure_revision_for_replace_accepts_only_above_initial_revision() { + assert!(ensure_revision_for_replace(Some(INITIAL_REVISION + 1)).is_ok()); + assert!(ensure_revision_for_replace(Some(INITIAL_REVISION + 100)).is_ok()); + } + + #[test] + fn ensure_revision_for_replace_rejects_missing_zero_and_initial_revision() { + let missing = ensure_revision_for_replace(None).expect_err("missing revision must error"); + assert!(missing.to_string().contains("InvalidArgument")); + assert!(missing.to_string().contains("must have a revision set")); + + let zero = + ensure_revision_for_replace(Some(0)).expect_err("revision 0 must error on replace"); + assert!(zero.to_string().contains("InvalidArgument")); + assert!(zero.to_string().contains("replace requires revision")); + + let initial = ensure_revision_for_replace(Some(INITIAL_REVISION)) + .expect_err("INITIAL_REVISION must error on replace path"); + assert!(initial.to_string().contains("InvalidArgument")); + assert!(initial.to_string().contains("replace requires revision")); + } + #[test] fn creation_entropy_fallback_regenerates_document_id() { let document_type = test_document_type(); diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index cad1f5e5103..b7d9f2446f0 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -476,10 +476,44 @@ impl Sdk { /// Marks identity nonce cache entries as stale so they are re-fetched from /// Platform on the next call to [`get_identity_nonce`] or /// [`get_identity_contract_nonce`]. + /// + /// Note: this **preserves** the cached nonce value, it does not roll it + /// back. Use this after **broadcast** failures, where the network may + /// have already observed the nonce and the cache must not regress past + /// what the chain has seen. For pre-broadcast (local) failures use + /// [`rollback_identity_contract_nonce`](Self::rollback_identity_contract_nonce) + /// instead. pub async fn refresh_identity_nonce(&self, identity_id: &Identifier) { self.nonce_cache.refresh(identity_id).await; } + /// Conditionally roll back a previously-bumped identity-contract nonce + /// after a **local** (pre-broadcast) failure. + /// + /// Call this only when the caller is certain the nonce was never observed + /// by the network — e.g. when build/sign or local structure validation + /// fails right after a successful + /// [`get_identity_contract_nonce`](Self::get_identity_contract_nonce) with + /// `bump_first = true`. The rollback is conditional: it only adjusts the + /// cache entry if its current nonce still equals `allocated_nonce`, + /// avoiding clobbering concurrent newer allocations. A missing or + /// already-advanced entry is left untouched. + /// + /// For broadcast failures keep using + /// [`refresh_identity_nonce`](Self::refresh_identity_nonce), which + /// preserves the cached (bumped) value so the cache cannot regress past + /// a nonce the network may have accepted. + pub async fn rollback_identity_contract_nonce( + &self, + identity_id: Identifier, + contract_id: Identifier, + allocated_nonce: IdentityNonce, + ) { + self.nonce_cache + .rollback_identity_contract_nonce(identity_id, contract_id, allocated_nonce) + .await; + } + /// Return [Dash Platform version](PlatformVersion) information used by this SDK. /// /// When auto-detection is enabled (default), returns [`PlatformVersion::latest()`] diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 80205527b04..f7f87918977 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -31,6 +31,19 @@ //! you need a "dry run" with no local nonce-cache side effects in this SDK instance, //! do not use the prepare API. //! +//! ### Pre-broadcast failures +//! +//! If a `prepareDocument*` call fails *before* the transition is broadcast (build, +//! sign, or local structure validation error), the bumped identity-contract nonce is +//! conditionally rolled back via rs-sdk's +//! [`Sdk::rollback_identity_contract_nonce`](dash_sdk::Sdk::rollback_identity_contract_nonce). +//! The rollback only adjusts the cache entry if it still equals the nonce allocated +//! by the failed attempt, so it does not clobber concurrent allocations. This makes +//! these errors safe to retry: a follow-up `prepareDocument*` call will reuse the +//! freed nonce instead of skipping it. Broadcast failures (which happen *after* +//! `prepareDocument*` returns) intentionally do **not** roll back, because the +//! network may have already observed the nonce. +//! //! ## One-shot document revision rules //! //! `documentCreate()` now accepts only documents whose revision is unset or @@ -54,7 +67,7 @@ use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::{ - build_signed_document_create_or_replace_transition, PutDocument, + build_signed_document_create_transition, build_signed_document_replace_transition, PutDocument, }; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; @@ -644,16 +657,17 @@ impl WasmSdk { let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build, sign, and structurally validate the state transition without - // broadcasting it. Errors here refresh the identity-contract nonce so - // it doesn't drift past Platform's view (the helper does that for us). - let state_transition = build_document_create_or_replace_transition( + // broadcasting it. Local pre-broadcast failures are rolled back inside + // rs-sdk so the identity-contract nonce cache cannot advance past a + // nonce the network never observed. + let state_transition = build_signed_document_create_transition( + self.inner_sdk(), &document, &document_type, Some(entropy_array), &identity_key, token_payment_info, &signer, - self.inner_sdk(), settings, ) .await?; @@ -744,16 +758,16 @@ impl WasmSdk { let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build, sign, and structurally validate the state transition without - // broadcasting it. Errors here refresh the identity-contract nonce so - // it doesn't drift past Platform's view (the helper does that for us). - let state_transition = build_document_create_or_replace_transition( + // broadcasting it. Local pre-broadcast failures are rolled back inside + // rs-sdk so the identity-contract nonce cache cannot advance past a + // nonce the network never observed. + let state_transition = build_signed_document_replace_transition( + self.inner_sdk(), &document, &document_type, - None, // entropy not needed for replace &identity_key, token_payment_info, &signer, - self.inner_sdk(), settings, ) .await?; @@ -893,9 +907,19 @@ impl WasmSdk { builder }; + // Pre-allocate the identity-contract nonce so that any pre-broadcast + // failure (sign or local structure validation) can be rolled back via + // rs-sdk's `rollback_identity_contract_nonce`. The rollback is + // conditional: it only adjusts the cache entry if it still equals the + // nonce allocated here, so it does not clobber concurrent allocations. + let allocated_nonce = self + .inner_sdk() + .get_identity_contract_nonce(owner_id, contract_id, true, settings) + .await?; + let state_transition = match builder - .sign( - self.inner_sdk(), + .sign_with_nonce( + allocated_nonce, &identity_key, &signer, self.inner_sdk().version(), @@ -904,7 +928,9 @@ impl WasmSdk { { Ok(st) => st, Err(err) => { - self.inner_sdk().refresh_identity_nonce(&owner_id).await; + self.inner_sdk() + .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) + .await; return Err(err.into()); } }; @@ -916,7 +942,9 @@ impl WasmSdk { if let Err(err) = ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version()) { - self.inner_sdk().refresh_identity_nonce(&owner_id).await; + self.inner_sdk() + .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) + .await; return Err(err.into()); } @@ -1293,47 +1321,6 @@ impl WasmSdk { // Helper Functions // ============================================================================ -/// Build, sign and validate a document create-or-replace state transition -/// without broadcasting it. -/// -/// This is a thin wrapper around rs-sdk's -/// [`build_signed_document_create_or_replace_transition`], adding wasm-sdk's -/// prepare-path policy: any error after the identity-contract nonce has been -/// allocated (build/sign or structure validation) refreshes the nonce cache so -/// future calls don't drift past Platform's view. -#[allow(clippy::too_many_arguments)] -async fn build_document_create_or_replace_transition( - document: &Document, - document_type: &DocumentType, - document_state_transition_entropy: Option<[u8; 32]>, - identity_public_key: &IdentityPublicKey, - token_payment_info: Option, - signer: &IdentitySignerWasm, - sdk: &dash_sdk::Sdk, - settings: Option, -) -> Result { - let owner_id = document.owner_id(); - - match build_signed_document_create_or_replace_transition( - sdk, - document, - document_type, - document_state_transition_entropy, - identity_public_key, - token_payment_info, - signer, - settings, - ) - .await - { - Ok(transition) => Ok(transition), - Err(err) => { - sdk.refresh_identity_nonce(&owner_id).await; - Err(err.into()) - } - } -} - fn ensure_document_create_revision( revision: Option, replace_api_name: &str, From 70c884bf734f53cd7a3382a90ab4f7321d58e61a Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 10 May 2026 09:11:34 -0500 Subject: [PATCH 14/24] fix(rs-sdk): tighten document transition nonce handling --- packages/rs-sdk-ffi/src/document/delete.rs | 64 ++++++- .../platform/documents/transitions/delete.rs | 53 +++++- .../src/platform/transition/put_document.rs | 175 ++++++++++++++++-- .../src/state_transitions/document.rs | 2 +- 4 files changed, 267 insertions(+), 27 deletions(-) diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index b1dbb23d439..9a0fa2260e7 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -1,5 +1,6 @@ //! Document deletion operations +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; @@ -153,23 +154,68 @@ pub unsafe extern "C" fn dash_sdk_document_delete( builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign( - &wrapper.sdk, + // Allocate the identity-contract nonce explicitly so any local + // pre-broadcast failure (sign/build or serialization) can be rolled + // back without leaving the rs-sdk nonce cache advanced past a nonce + // the network never observed. We do not broadcast in this prepared + // path, so all post-allocation failures here are local. + let contract_id_for_nonce = data_contract.id(); + let identity_contract_nonce = wrapper + .sdk + .get_identity_contract_nonce(owner_identifier, contract_id_for_nonce, true, settings) + .await + .map_err(|e| { + FFIError::InternalError(format!( + "Failed to allocate identity-contract nonce: {}", + e + )) + })?; + + let state_transition = match builder + .sign_with_nonce( + identity_contract_nonce, identity_public_key, signer, wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create delete transition: {}", e)) - })?; + { + Ok(transition) => transition, + Err(e) => { + wrapper + .sdk + .rollback_identity_contract_nonce( + owner_identifier, + contract_id_for_nonce, + identity_contract_nonce, + ) + .await; + return Err(FFIError::InternalError(format!( + "Failed to create delete transition: {}", + e + ))); + } + }; // Serialize the state transition with bincode let config = bincode::config::standard(); - let serialized = bincode::encode_to_vec(&state_transition, config).map_err(|e| { - FFIError::InternalError(format!("Failed to serialize state transition: {}", e)) - })?; + let serialized = match bincode::encode_to_vec(&state_transition, config) { + Ok(bytes) => bytes, + Err(e) => { + wrapper + .sdk + .rollback_identity_contract_nonce( + owner_identifier, + contract_id_for_nonce, + identity_contract_nonce, + ) + .await; + return Err(FFIError::InternalError(format!( + "Failed to serialize state transition: {}", + e + ))); + } + }; debug!( size = serialized.len(), "[DOCUMENT DELETE] serialized transition size (bytes)" diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index b0b36a51f7c..0366e94286e 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -354,6 +354,18 @@ impl Sdk { /// This method broadcasts a document deletion transition to permanently remove /// a document from the platform. The result confirms the deletion. /// + /// # Nonce handling on local errors + /// + /// The identity-contract nonce is allocated explicitly before signing so + /// that **pre-broadcast** failures (sign/build error or local structure + /// validation error) can be rolled back via + /// [`Sdk::rollback_identity_contract_nonce`]. The local cache therefore + /// does not advance past a nonce the network never observed. Broadcast + /// failures are not rolled back here; they continue to rely on the + /// existing [`broadcast_and_wait`](crate::platform::transition::broadcast::BroadcastStateTransition::broadcast_and_wait) + /// refresh behavior because the network may already have observed the + /// nonce. + /// /// # Arguments /// /// * `delete_document_transition_builder` - Builder containing document deletion parameters @@ -381,13 +393,48 @@ impl Sdk { let platform_version = self.version(); let put_settings = delete_document_transition_builder.settings; + let owner_id = delete_document_transition_builder.owner_id; + let contract_id = delete_document_transition_builder.data_contract.id(); - let state_transition = delete_document_transition_builder - .sign(self, signing_key, signer, platform_version) + // Allocate the identity-contract nonce explicitly so we can roll it + // back on pre-broadcast failures without leaving the local cache + // advanced past a nonce the network never observed. + let identity_contract_nonce = self + .get_identity_contract_nonce(owner_id, contract_id, true, put_settings) .await?; - ensure_valid_state_transition_structure(&state_transition, platform_version)?; + let state_transition = match delete_document_transition_builder + .sign_with_nonce( + identity_contract_nonce, + signing_key, + signer, + platform_version, + ) + .await + { + Ok(transition) => transition, + Err(err) => { + self.rollback_identity_contract_nonce( + owner_id, + contract_id, + identity_contract_nonce, + ) + .await; + return Err(err); + } + }; + + if let Err(err) = + ensure_valid_state_transition_structure(&state_transition, platform_version) + { + self.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) + .await; + return Err(err); + } + // Broadcast: do NOT roll back on broadcast failure — the network may + // already have observed the nonce. broadcast_and_wait keeps the + // existing refresh behavior on its own failures. let proof_result = state_transition .broadcast_and_wait::(self, put_settings) .await?; diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index d1bfcdce52f..cfebdd535c9 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -16,7 +16,7 @@ use dpp::state_transition::StateTransition; use dpp::tokens::token_payment_info::TokenPaymentInfo; fn is_document_replace_revision(revision: Option) -> bool { - revision.is_some_and(|rev| rev != INITIAL_REVISION) + revision.is_some_and(|rev| rev > INITIAL_REVISION) } /// Reject documents whose revision is `Some(0)` for the create-or-replace @@ -210,6 +210,12 @@ pub async fn build_signed_document_create_or_replace_transition, + document_state_transition_entropy: [u8; 32], identity_public_key: &IdentityPublicKey, token_payment_info: Option, signer: &S, @@ -228,7 +234,7 @@ pub async fn build_signed_document_create_transition> PutDocument for Document { signer: &S, settings: Option, ) -> Result { - let transition = build_signed_document_create_or_replace_transition( - sdk, - self, - &document_type, - document_state_transition_entropy, - &identity_public_key, - token_payment_info, - signer, - settings, - ) - .await?; + // Route through the strict create/replace helpers so callers get the + // same fail-fast revision-vs-intent guarantees as the wasm-sdk + // `prepareDocumentCreate` / `prepareDocumentReplace` paths. The + // dispatch is driven by the document revision: unset or + // `INITIAL_REVISION` selects create; revisions strictly greater than + // `INITIAL_REVISION` select replace; `Some(0)` is rejected by the + // strict replace helper before any nonce allocation. + let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { + // Create path: entropy is required. Reject `None` *before* we + // allocate a nonce so we never advance the cache for an + // attempt we know cannot succeed. We deliberately do NOT + // silently fall back to regenerating the document id — the + // caller's id must match the entropy they supplied. + let entropy = document_state_transition_entropy.ok_or_else(|| { + Error::Generic( + "InvalidArgument: document_state_transition_entropy is required \ + when calling put_to_platform on the create path; supply the \ + 32-byte entropy that was used to derive the document id" + .to_string(), + ) + })?; + build_signed_document_create_transition( + sdk, + self, + &document_type, + entropy, + &identity_public_key, + token_payment_info, + signer, + settings, + ) + .await? + } else { + // Replace path: entropy is unused; the strict helper enforces + // `revision > INITIAL_REVISION`. + build_signed_document_replace_transition( + sdk, + self, + &document_type, + &identity_public_key, + token_payment_info, + signer, + settings, + ) + .await? + }; // response is empty for a broadcast, result comes from the stream wait for state transition result transition @@ -450,6 +491,7 @@ mod tests { #[test] fn branch_selection_uses_revision_rules() { assert!(!is_document_replace_revision(None)); + assert!(!is_document_replace_revision(Some(0))); assert!(!is_document_replace_revision(Some(INITIAL_REVISION))); assert!(is_document_replace_revision(Some(INITIAL_REVISION + 1))); } @@ -541,4 +583,109 @@ mod tests { assert_eq!(resolved_entropy, provided_entropy); assert_eq!(resolved_document.id(), original_id); } + + /// Failing-signer used by the rollback test below to deterministically + /// fail signing **after** nonce allocation. Mirrors the nonce-cache test + /// pattern in `internal_cache::mod` (`rollback_decrements_when_cache_matches_allocated_nonce`). + #[derive(Debug)] + struct AlwaysFailingSigner; + + #[async_trait::async_trait] + impl dpp::identity::signer::Signer for AlwaysFailingSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(dpp::ProtocolError::Generic( + "deliberate signing failure for rollback test".to_string(), + )) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + unreachable!("not used by document create transition signing") + } + + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } + } + + /// Pre-broadcast signing failure inside the strict create helper must + /// roll the identity-contract nonce back so the cache does not advance + /// past a nonce the network never observed. Asserting via "next + /// allocation reuses the rolled-back value" mirrors the rollback test + /// pattern in `internal_cache::mod` so future readers can map the two. + #[tokio::test] + async fn build_signed_document_create_rolls_back_nonce_on_signing_failure() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let document_type = test_document_type(); + let contract_id = document_type.data_contract_id(); + let entropy = [0u8; 32]; + let document = test_document(None, Identifier::from([3; 32])); + let owner_id = document.owner_id(); + + // Build a key whose purpose / security level / enabled flag pass the + // BatchTransition pre-sign verification, so the failure happens + // inside `signer.sign` (i.e. *after* nonce allocation), not earlier. + let identity_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0u8; 33]), + disabled_at: None, + }); + + let mut sdk = crate::Sdk::new_mock(); + sdk.mock() + .expect_fetch::( + (owner_id, contract_id), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set IdentityContractNonceFetcher mock expectation"); + + let signer = AlwaysFailingSigner; + + let err = build_signed_document_create_transition( + &sdk, + &document, + &document_type, + entropy, + &identity_key, + None, + &signer, + None, + ) + .await + .expect_err("signer failure must surface so the helper can roll back the allocated nonce"); + + assert!( + err.to_string().contains("deliberate signing failure"), + "expected the signer's failure to surface, got: {err}" + ); + + // Cache was bumped from platform=10 to 11 during the failed attempt + // and then rolled back to 10. Re-allocating with bump_first=true + // must yield 11 again — proving the rolled-back nonce is reusable. + let next = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, None) + .await + .expect("nonce allocation must succeed after rollback"); + assert_eq!( + next, 11, + "rolled-back nonce should be reused by the next allocation" + ); + } } diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index f7f87918977..8edc08499b9 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -664,7 +664,7 @@ impl WasmSdk { self.inner_sdk(), &document, &document_type, - Some(entropy_array), + entropy_array, &identity_key, token_payment_info, &signer, From f4416d814cbc7ec6753a71d9642a012325a5859f Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sun, 10 May 2026 10:29:07 -0500 Subject: [PATCH 15/24] fix(rs-sdk): address document prepare review findings --- packages/rs-sdk-ffi/src/document/delete.rs | 22 ++ .../platform/documents/transitions/delete.rs | 112 +++++++ .../src/platform/transition/put_document.rs | 301 +++++++++++++++--- 3 files changed, 384 insertions(+), 51 deletions(-) diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index 9a0fa2260e7..5cdc2b36d72 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -5,6 +5,7 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; +use dash_sdk::platform::transition::validation::ensure_valid_state_transition_structure; use dash_sdk::platform::IdentityPublicKey; use drive_proof_verifier::ContextProvider; use std::ffi::CStr; @@ -197,6 +198,27 @@ pub unsafe extern "C" fn dash_sdk_document_delete( } }; + // Run local structure validation before serialization. Like the + // sign step above, any failure here is *pre-broadcast* and must + // roll the identity-contract nonce back so the local cache does + // not advance past a nonce the network never observed. + if let Err(e) = + ensure_valid_state_transition_structure(&state_transition, wrapper.sdk.version()) + { + wrapper + .sdk + .rollback_identity_contract_nonce( + owner_identifier, + contract_id_for_nonce, + identity_contract_nonce, + ) + .await; + return Err(FFIError::InternalError(format!( + "Delete transition failed structure validation: {}", + e + ))); + } + // Serialize the state transition with bincode let config = bincode::config::standard(); let serialized = match bincode::encode_to_vec(&state_transition, config) { diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 0366e94286e..4ee6c220b16 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -346,6 +346,118 @@ mod tests { None ); } + + /// Failing-signer used by the rollback test below to deterministically + /// fail signing **after** nonce allocation. Mirrors the pattern in + /// `put_document.rs` so a future reader can map the two. + #[derive(Debug)] + struct AlwaysFailingSigner; + + #[async_trait::async_trait] + impl dpp::identity::signer::Signer for AlwaysFailingSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(dpp::ProtocolError::Generic( + "deliberate signing failure for delete rollback test".to_string(), + )) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + unreachable!("not used by document delete transition signing") + } + + fn can_sign_with(&self, _key: &IdentityPublicKey) -> bool { + true + } + } + + /// Pre-broadcast signing failure inside `Sdk::document_delete` must + /// roll the identity-contract nonce back so the cache does not advance + /// past a nonce the network never observed. Asserting via "next + /// allocation reuses the rolled-back value" mirrors the put_document + /// rollback test pattern. + #[tokio::test] + async fn document_delete_rolls_back_nonce_on_signing_failure() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + let contract_id = data_contract.id(); + let owner_id = Identifier::from([7u8; 32]); + let document_id = Identifier::from([3u8; 32]); + + // Build a key whose purpose / security level / enabled flag pass the + // BatchTransition pre-sign verification, so the failure happens + // inside `signer.sign` (i.e. *after* nonce allocation), not earlier. + let identity_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0u8; 33]), + disabled_at: None, + }); + + let mut sdk = crate::Sdk::new_mock(); + sdk.mock() + .expect_fetch::( + (owner_id, contract_id), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set IdentityContractNonceFetcher mock expectation"); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract.clone(), + "niceDocument".to_string(), + document_id, + owner_id, + ); + + let signer = AlwaysFailingSigner; + + let err = sdk + .document_delete(builder, &identity_key, &signer) + .await + .expect_err( + "signer failure must surface so document_delete can roll back the allocated nonce", + ); + + assert!( + err.to_string().contains("deliberate signing failure"), + "expected the signer's failure to surface, got: {err}" + ); + + // Cache was bumped from platform=10 to 11 during the failed attempt + // and then rolled back to 10. Re-allocating with bump_first=true + // must yield 11 again — proving the rolled-back nonce is reusable. + let next = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, None) + .await + .expect("nonce allocation must succeed after rollback"); + assert_eq!( + next, 11, + "rolled-back nonce should be reused by the next allocation" + ); + } } impl Sdk { diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index cfebdd535c9..cdafa3a75c3 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -1,3 +1,28 @@ +//! Document put / create / replace state-transition builders. +//! +//! # Compatibility note (2026-05) +//! +//! Two intentionally different create-path entry points coexist: +//! +//! - The [`PutDocument::put_to_platform`] trait method is the **legacy +//! native** entry point. It accepts +//! `document_state_transition_entropy = None` on the create path and will +//! auto-generate 32-byte entropy + rewrite `document.id` via +//! [`Document::generate_document_id_v0`] before signing. In-tree callers +//! such as `rs-platform-wallet` (DashPay profile creation) rely on this +//! fallback. +//! - The strict [`build_signed_document_create_transition`] / +//! [`build_signed_document_replace_transition`] helpers, used by the +//! wasm-sdk `prepareDocumentCreate` / `prepareDocumentReplace` flows, do +//! **not** auto-generate entropy. Callers must supply entropy whose +//! derived `Document::generate_document_id_v0(...)` matches `document.id`; +//! a mismatch is rejected before any identity-contract nonce is +//! allocated. +//! +//! New prepare/sign-without-broadcast call sites should prefer the strict +//! builders so the supplied document id and entropy commit to the same +//! value. + use super::broadcast::BroadcastStateTransition; use super::validation::ensure_valid_state_transition_structure; use super::waitable::Waitable; @@ -69,6 +94,41 @@ fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { } } +/// Strict create-path id check: documents handed to +/// [`build_signed_document_create_transition`] must already have their `id` +/// derived from the supplied entropy via [`Document::generate_document_id_v0`]. +/// +/// This guards against silently signing a transition whose committed +/// document id does not match the entropy bound into the create transition. +/// Callers that want id auto-generation should use the legacy +/// [`PutDocument::put_to_platform`] trait method, which still accepts +/// `entropy = None` and rewrites the document id before signing. +fn ensure_document_id_matches_entropy( + document: &Document, + document_type: &DocumentType, + entropy: &[u8; 32], +) -> Result<(), Error> { + let expected = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + entropy.as_slice(), + ); + if document.id() != expected { + return Err(Error::Generic(format!( + "InvalidArgument: document.id does not match \ + generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \ + expected {expected}, got {got}. \ + Either set document.id to the derived value before calling \ + build_signed_document_create_transition, or use the legacy \ + PutDocument::put_to_platform trait method which auto-generates \ + entropy and rewrites the document id when entropy is None.", + got = document.id() + ))); + } + Ok(()) +} + fn resolve_document_create_entropy( document: &Document, document_type: &DocumentType, @@ -121,25 +181,35 @@ pub trait PutDocument>: Waitable { ) -> Result; } -/// Build, sign, and structurally validate a document create-or-replace -/// [`StateTransition`] without broadcasting it. +/// Internal dispatch: build, sign, and structurally validate a document +/// create-or-replace [`StateTransition`] without broadcasting it. /// -/// This is the pre-broadcast half of [`PutDocument::put_to_platform`]: it -/// allocates a fresh identity-contract nonce, picks the create-vs-replace -/// branch based on the document's revision, fills in entropy when missing, +/// This is intentionally **not** part of the public API. It is the +/// pre-broadcast core shared by [`build_signed_document_create_transition`], +/// [`build_signed_document_replace_transition`], and the legacy +/// [`PutDocument::put_to_platform`] trait method. It allocates a fresh +/// identity-contract nonce, picks the create-vs-replace branch based on the +/// document's revision, falls back to RNG-derived entropy + id auto-rewrite on +/// the create branch when `document_state_transition_entropy` is `None`, /// applies `user_fee_increase` / `token_payment_info` / /// `state_transition_creation_options` from `settings`, signs the transition, -/// and runs structure validation. The caller decides whether (and how) to -/// broadcast the returned, signed transition. +/// and runs structure validation. +/// +/// # Why this is not public +/// +/// The auto-fallback create branch (entropy `None` → RNG entropy + rewritten +/// document id) is convenient for the legacy `PutDocument` trait but is a +/// footgun for prepare/sign-without-broadcast flows like the wasm-sdk's +/// `prepareDocumentCreate`, where the caller's already-derived document id +/// must commit to the entropy they pass in. Public callers must go through +/// [`build_signed_document_create_transition`] (which enforces the strict +/// id-matches-entropy check) or [`build_signed_document_replace_transition`]. /// /// # Revision validation /// /// The dispatch is driven by the document revision and rejects only the -/// always-invalid `Some(0)` case. Use -/// [`build_signed_document_create_transition`] or -/// [`build_signed_document_replace_transition`] for fail-fast validation that -/// also enforces caller intent (mismatched create/replace revisions error -/// before any nonce allocation). +/// always-invalid `Some(0)` case. Strict per-intent validation lives in the +/// public create / replace helpers, which run before any nonce allocation. /// /// # Nonce handling on local errors /// @@ -151,7 +221,7 @@ pub trait PutDocument>: Waitable { /// the cache entry if it still equals the nonce allocated by this attempt, so /// concurrent allocations are not clobbered. #[allow(clippy::too_many_arguments)] -pub async fn build_signed_document_create_or_replace_transition>( +async fn build_signed_document_create_or_replace_transition>( sdk: &Sdk, document: &Document, document_type: &DocumentType, @@ -202,22 +272,27 @@ pub async fn build_signed_document_create_or_replace_transition>( sdk: &Sdk, @@ -230,6 +305,14 @@ pub async fn build_signed_document_create_transition, ) -> Result { ensure_revision_for_create(document.revision())?; + // Verify the caller's document id matches the entropy *before* we + // allocate any identity-contract nonce, so a stale/wrong id never + // bumps the local nonce cache. + ensure_document_id_matches_entropy( + document, + document_type, + &document_state_transition_entropy, + )?; build_signed_document_create_or_replace_transition( sdk, document, @@ -246,16 +329,17 @@ pub async fn build_signed_document_create_transition>( sdk: &Sdk, @@ -335,6 +419,18 @@ async fn build_and_sign_create_or_replace_after_nonce> PutDocument for Document { + /// Legacy native put-document entry point. + /// + /// **Backwards-compatibility note:** unlike the strict + /// [`build_signed_document_create_transition`] / wasm + /// `prepareDocumentCreate` builders, this trait method accepts + /// `document_state_transition_entropy = None` on the create path and + /// auto-generates 32-byte entropy + rewrites `document.id` via + /// [`Document::generate_document_id_v0`] before signing. This preserves + /// the original `PutDocument` behavior used by in-tree callers such as + /// `rs-platform-wallet` profile creation. New prepare/sign-without-broadcast + /// call sites should use the strict create/replace builders so + /// the supplied document id and entropy commit to the same value. async fn put_to_platform( &self, sdk: &Sdk, @@ -353,24 +449,23 @@ impl> PutDocument for Document { // `INITIAL_REVISION` select replace; `Some(0)` is rejected by the // strict replace helper before any nonce allocation. let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { - // Create path: entropy is required. Reject `None` *before* we - // allocate a nonce so we never advance the cache for an - // attempt we know cannot succeed. We deliberately do NOT - // silently fall back to regenerating the document id — the - // caller's id must match the entropy they supplied. - let entropy = document_state_transition_entropy.ok_or_else(|| { - Error::Generic( - "InvalidArgument: document_state_transition_entropy is required \ - when calling put_to_platform on the create path; supply the \ - 32-byte entropy that was used to derive the document id" - .to_string(), - ) - })?; + // Create path. Preserve legacy behavior: when the caller did not + // supply entropy, generate it and rewrite `document.id` so the + // pair stays consistent before we hand the (document, entropy) + // to the strict create helper. The strict helper still verifies + // that `document.id == generate_document_id_v0(entropy)` before + // allocating any nonce, so the legacy fallback cannot mask an + // id/entropy mismatch. + let (resolved_document, resolved_entropy) = resolve_document_create_entropy( + self, + &document_type, + document_state_transition_entropy, + ); build_signed_document_create_transition( sdk, - self, + &resolved_document, &document_type, - entropy, + resolved_entropy, &identity_public_key, token_payment_info, signer, @@ -630,8 +725,18 @@ mod tests { let document_type = test_document_type(); let contract_id = document_type.data_contract_id(); let entropy = [0u8; 32]; - let document = test_document(None, Identifier::from([3; 32])); - let owner_id = document.owner_id(); + // Derive the document id from the entropy so the strict create + // helper's id-matches-entropy guard passes and the failure happens + // *after* nonce allocation, where rollback is what we're testing. + let owner_id = Identifier::from([7; 32]); + let derived_id = Document::generate_document_id_v0( + &contract_id, + &owner_id, + document_type.name(), + entropy.as_slice(), + ); + let document = test_document(None, derived_id); + assert_eq!(document.owner_id(), owner_id); // Build a key whose purpose / security level / enabled flag pass the // BatchTransition pre-sign verification, so the failure happens @@ -688,4 +793,98 @@ mod tests { "rolled-back nonce should be reused by the next allocation" ); } + + /// The strict create helper must reject a document whose id does not + /// match the supplied entropy *before* it allocates an + /// identity-contract nonce. The post-condition we assert is: + /// the very next nonce allocation (with `bump_first=true`) returns the + /// expected first-bump value (1 over the platform-fetched 10), which + /// proves the failed call did not bump the cache. + #[tokio::test] + async fn build_signed_document_create_rejects_id_entropy_mismatch_before_nonce_alloc() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let document_type = test_document_type(); + let contract_id = document_type.data_contract_id(); + let entropy = [0u8; 32]; + // Intentionally use a document id that does NOT match + // generate_document_id_v0(.., entropy = [0; 32]). + let bogus_id = Identifier::from([0xAB; 32]); + let document = test_document(None, bogus_id); + let owner_id = document.owner_id(); + + // Sanity-check that the bogus id really does not match the + // expected derived id, otherwise this test would silently pass for + // the wrong reason. + let expected_id = Document::generate_document_id_v0( + &contract_id, + &owner_id, + document_type.name(), + entropy.as_slice(), + ); + assert_ne!( + bogus_id, expected_id, + "test fixture must use an id that does not match the entropy" + ); + + let identity_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0u8; 33]), + disabled_at: None, + }); + + let mut sdk = crate::Sdk::new_mock(); + // Pre-load the platform-side nonce so a later `bump_first=true` + // allocation has a deterministic value to return. If the strict + // helper had (incorrectly) allocated a nonce before failing, the + // first post-failure allocation would jump to 12 instead of 11. + sdk.mock() + .expect_fetch::( + (owner_id, contract_id), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set IdentityContractNonceFetcher mock expectation"); + + let signer = AlwaysFailingSigner; + + let err = build_signed_document_create_transition( + &sdk, + &document, + &document_type, + entropy, + &identity_key, + None, + &signer, + None, + ) + .await + .expect_err("id-mismatch must error before nonce allocation"); + + let msg = err.to_string(); + assert!(msg.contains("InvalidArgument"), "msg: {msg}"); + assert!( + msg.contains("does not match"), + "expected id-mismatch error, got: {msg}" + ); + + // No nonce allocation happened during the failed call, so the + // first allocation now should be the platform value + 1 = 11. + let next = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, None) + .await + .expect("nonce allocation must succeed after rejected attempt"); + assert_eq!( + next, 11, + "id-mismatch must reject before nonce allocation; next allocation should be 11" + ); + } } From 8c607eddb833865d2975f59c5d65b50512b09569 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 12 May 2026 00:18:10 -0500 Subject: [PATCH 16/24] fix(rs-sdk): share document delete prepare helper --- packages/rs-sdk-ffi/src/document/delete.rs | 120 +++------- .../platform/documents/transitions/delete.rs | 209 ++++++++++++++---- .../src/platform/documents/transitions/mod.rs | 4 +- .../src/platform/transition/put_document.rs | 75 +++++-- packages/rs-sdk/src/sdk.rs | 17 ++ .../src/state_transitions/document.rs | 56 ++--- 6 files changed, 284 insertions(+), 197 deletions(-) diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index 5cdc2b36d72..4f2ee97b1a3 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -1,11 +1,11 @@ //! Document deletion operations -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{Identifier, UserFeeIncrease}; -use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; -use dash_sdk::platform::transition::validation::ensure_valid_state_transition_structure; +use dash_sdk::platform::documents::transitions::{ + build_signed_document_delete_transition, DocumentDeleteTransitionBuilder, +}; use dash_sdk::platform::IdentityPublicKey; use drive_proof_verifier::ContextProvider; use std::ffi::CStr; @@ -155,89 +155,39 @@ pub unsafe extern "C" fn dash_sdk_document_delete( builder = builder.with_state_transition_creation_options(options); } - // Allocate the identity-contract nonce explicitly so any local - // pre-broadcast failure (sign/build or serialization) can be rolled - // back without leaving the rs-sdk nonce cache advanced past a nonce - // the network never observed. We do not broadcast in this prepared - // path, so all post-allocation failures here are local. - let contract_id_for_nonce = data_contract.id(); - let identity_contract_nonce = wrapper - .sdk - .get_identity_contract_nonce(owner_identifier, contract_id_for_nonce, true, settings) - .await - .map_err(|e| { - FFIError::InternalError(format!( - "Failed to allocate identity-contract nonce: {}", - e - )) - })?; - - let state_transition = match builder - .sign_with_nonce( - identity_contract_nonce, - identity_public_key, - signer, - wrapper.sdk.version(), - ) - .await - { - Ok(transition) => transition, - Err(e) => { - wrapper - .sdk - .rollback_identity_contract_nonce( - owner_identifier, - contract_id_for_nonce, - identity_contract_nonce, - ) - .await; - return Err(FFIError::InternalError(format!( - "Failed to create delete transition: {}", - e - ))); - } - }; - - // Run local structure validation before serialization. Like the - // sign step above, any failure here is *pre-broadcast* and must - // roll the identity-contract nonce back so the local cache does - // not advance past a nonce the network never observed. - if let Err(e) = - ensure_valid_state_transition_structure(&state_transition, wrapper.sdk.version()) - { - wrapper - .sdk - .rollback_identity_contract_nonce( - owner_identifier, - contract_id_for_nonce, - identity_contract_nonce, - ) - .await; - return Err(FFIError::InternalError(format!( - "Delete transition failed structure validation: {}", - e - ))); - } - - // Serialize the state transition with bincode + // Delegate the nonce-allocate / sign / structure-validate / rollback + // sequence to rs-sdk's shared helper. Any pre-broadcast failure + // inside the helper (sign or local structure validation) rolls the + // bumped identity-contract nonce back internally, so the local + // nonce cache cannot advance past a nonce the network never + // observed. + let state_transition = build_signed_document_delete_transition( + &wrapper.sdk, + &builder, + identity_public_key, + signer, + ) + .await + .map_err(|e| { + FFIError::InternalError(format!("Failed to create delete transition: {}", e)) + })?; + + // Serialize the state transition with bincode. + // + // Note on nonce rollback: at this point the shared helper has + // already allocated and embedded the identity-contract nonce in + // `state_transition` and consumed its rollback handle. `bincode` + // encoding into a `Vec` writes to an in-memory buffer with no + // IO, and `Encode` for `StateTransition`'s nested data types has + // no failure mode in practice, so this step is effectively + // infallible for any transition the helper just returned. A + // failure here would leave the local nonce cache advanced by one + // until the caller's next refresh, but cannot leave the network + // in an inconsistent state because nothing has been broadcast. let config = bincode::config::standard(); - let serialized = match bincode::encode_to_vec(&state_transition, config) { - Ok(bytes) => bytes, - Err(e) => { - wrapper - .sdk - .rollback_identity_contract_nonce( - owner_identifier, - contract_id_for_nonce, - identity_contract_nonce, - ) - .await; - return Err(FFIError::InternalError(format!( - "Failed to serialize state transition: {}", - e - ))); - } - }; + let serialized = bincode::encode_to_vec(&state_transition, config).map_err(|e| { + FFIError::InternalError(format!("Failed to serialize state transition: {}", e)) + })?; debug!( size = serialized.len(), "[DOCUMENT DELETE] serialized transition size (bytes)" diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 4ee6c220b16..5b94bea2214 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -115,7 +115,15 @@ impl DocumentDeleteTransitionBuilder { self } - /// Adds settings to the document delete transition + /// Adds settings to the document delete transition. + /// + /// Explicit setters always win regardless of call order: if + /// [`Self::with_user_fee_increase`] or + /// [`Self::with_state_transition_creation_options`] has already been + /// called on this builder, the corresponding field in `settings` is + /// only used as a fallback when the dedicated builder field is still + /// `None`. This makes the builder order-independent for these two + /// fields and avoids silently clobbering a deliberate caller choice. /// /// # Arguments /// @@ -125,12 +133,17 @@ impl DocumentDeleteTransitionBuilder { /// /// * `Self` - The updated builder pub fn with_settings(mut self, settings: PutSettings) -> Self { - if let Some(user_fee_increase) = settings.user_fee_increase { - self.user_fee_increase = Some(user_fee_increase); + if self.user_fee_increase.is_none() { + if let Some(user_fee_increase) = settings.user_fee_increase { + self.user_fee_increase = Some(user_fee_increase); + } } - if let Some(state_transition_creation_options) = settings.state_transition_creation_options - { - self.state_transition_creation_options = Some(state_transition_creation_options); + if self.state_transition_creation_options.is_none() { + if let Some(state_transition_creation_options) = + settings.state_transition_creation_options + { + self.state_transition_creation_options = Some(state_transition_creation_options); + } } self.settings = Some(settings); self @@ -260,6 +273,68 @@ pub enum DocumentDeleteResult { Deleted(Identifier), } +/// Build, sign, and structurally validate a document **delete** +/// [`StateTransition`] without broadcasting it. +/// +/// This is the shared pre-broadcast core used by [`Sdk::document_delete`] +/// and the wasm-sdk / FFI prepare-document-delete paths so the +/// nonce-allocate / sign / validate / rollback sequence is implemented in +/// exactly one place. +/// +/// Concretely, this helper: +/// +/// - allocates a fresh identity-contract nonce via +/// [`Sdk::get_identity_contract_nonce`] with `bump_first = true`, +/// - signs the transition by delegating to +/// [`DocumentDeleteTransitionBuilder::sign_with_nonce`], +/// - runs structure validation via +/// [`ensure_valid_state_transition_structure`], and +/// - on any **pre-broadcast** error (sign/build or local structure +/// validation) conditionally rolls the bumped identity-contract nonce +/// back via [`Sdk::rollback_identity_contract_nonce`], so the local +/// cache does not advance past a nonce the network never observed. +/// +/// Errors that occur **after** this helper returns successfully (e.g. +/// serialization failures in callers) are not rolled back here. +pub async fn build_signed_document_delete_transition>( + sdk: &Sdk, + builder: &DocumentDeleteTransitionBuilder, + identity_public_key: &IdentityPublicKey, + signer: &S, +) -> Result { + let owner_id = builder.owner_id; + let contract_id = builder.data_contract.id(); + + let identity_contract_nonce = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, builder.settings) + .await?; + + let state_transition = match builder + .sign_with_nonce( + identity_contract_nonce, + identity_public_key, + signer, + sdk.version(), + ) + .await + { + Ok(transition) => transition, + Err(err) => { + sdk.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) + .await; + return Err(err); + } + }; + + if let Err(err) = ensure_valid_state_transition_structure(&state_transition, sdk.version()) { + sdk.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) + .await; + return Err(err); + } + + Ok(state_transition) +} + #[cfg(test)] mod tests { use super::*; @@ -301,6 +376,80 @@ mod tests { ); } + #[test] + fn with_settings_does_not_overwrite_explicit_user_fee_increase() { + let settings_with_seven = PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }; + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_user_fee_increase(42) + .with_settings(settings_with_seven); + + assert_eq!( + builder.user_fee_increase, + Some(42), + "explicit with_user_fee_increase(42) must win over settings.user_fee_increase = Some(7)" + ); + } + + #[test] + fn with_settings_does_not_overwrite_explicit_state_transition_creation_options() { + let explicit_options = StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }; + let settings_options = StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }; + assert_ne!( + explicit_options, settings_options, + "test precondition: explicit and settings options must differ to prove which one wins" + ); + let settings_with_options = PutSettings { + state_transition_creation_options: Some(settings_options), + ..Default::default() + }; + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_state_transition_creation_options(explicit_options) + .with_settings(settings_with_options); + + assert_eq!( + builder.state_transition_creation_options, + Some(explicit_options), + "explicit with_state_transition_creation_options must win over settings value" + ); + } + #[test] fn with_settings_preserves_explicit_fields_when_settings_values_are_none() { let explicit_creation_options = StateTransitionCreationOptions::default(); @@ -502,47 +651,17 @@ impl Sdk { signing_key: &IdentityPublicKey, signer: &S, ) -> Result { - let platform_version = self.version(); - let put_settings = delete_document_transition_builder.settings; - let owner_id = delete_document_transition_builder.owner_id; - let contract_id = delete_document_transition_builder.data_contract.id(); - - // Allocate the identity-contract nonce explicitly so we can roll it - // back on pre-broadcast failures without leaving the local cache - // advanced past a nonce the network never observed. - let identity_contract_nonce = self - .get_identity_contract_nonce(owner_id, contract_id, true, put_settings) - .await?; - let state_transition = match delete_document_transition_builder - .sign_with_nonce( - identity_contract_nonce, - signing_key, - signer, - platform_version, - ) - .await - { - Ok(transition) => transition, - Err(err) => { - self.rollback_identity_contract_nonce( - owner_id, - contract_id, - identity_contract_nonce, - ) - .await; - return Err(err); - } - }; - - if let Err(err) = - ensure_valid_state_transition_structure(&state_transition, platform_version) - { - self.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) - .await; - return Err(err); - } + // Build/sign/validate via the shared helper so the nonce + // allocate/sign/validate/rollback sequence stays in one place. + let state_transition = build_signed_document_delete_transition( + self, + &delete_document_transition_builder, + signing_key, + signer, + ) + .await?; // Broadcast: do NOT roll back on broadcast failure — the network may // already have observed the nonce. broadcast_and_wait keeps the diff --git a/packages/rs-sdk/src/platform/documents/transitions/mod.rs b/packages/rs-sdk/src/platform/documents/transitions/mod.rs index 200a2164267..d5a31ba9b5e 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/mod.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/mod.rs @@ -6,7 +6,9 @@ pub mod set_price; pub mod transfer; pub use create::{DocumentCreateResult, DocumentCreateTransitionBuilder}; -pub use delete::{DocumentDeleteResult, DocumentDeleteTransitionBuilder}; +pub use delete::{ + build_signed_document_delete_transition, DocumentDeleteResult, DocumentDeleteTransitionBuilder, +}; pub use purchase::{DocumentPurchaseResult, DocumentPurchaseTransitionBuilder}; pub use replace::{DocumentReplaceResult, DocumentReplaceTransitionBuilder}; pub use set_price::{DocumentSetPriceResult, DocumentSetPriceTransitionBuilder}; diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index cdafa3a75c3..68eba966202 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -22,6 +22,19 @@ //! New prepare/sign-without-broadcast call sites should prefer the strict //! builders so the supplied document id and entropy commit to the same //! value. +//! +//! Additionally, [`PutDocument::put_to_platform`]'s create path now +//! locally rejects when `document_state_transition_entropy = Some(entropy)` +//! and the derived id does not match `document.id` — the strict +//! [`build_signed_document_create_transition`] helper that backs it +//! enforces this id-matches-entropy check before any nonce allocation. +//! Passing `None` still preserves the legacy auto-generate-entropy / +//! rewrite-id behavior for callers that opt into it. +//! +//! [`build_signed_document_create_or_replace_transition`] remains public +//! for source compatibility with downstream native callers that depended +//! on it before the strict helpers were introduced. New callers should +//! prefer the strict create/replace helpers above. use super::broadcast::BroadcastStateTransition; use super::validation::ensure_valid_state_transition_structure; @@ -181,29 +194,33 @@ pub trait PutDocument>: Waitable { ) -> Result; } -/// Internal dispatch: build, sign, and structurally validate a document -/// create-or-replace [`StateTransition`] without broadcasting it. +/// Legacy create-or-replace dispatch: build, sign, and structurally validate +/// a document [`StateTransition`] without broadcasting it. +/// +/// **Compatibility note (kept `pub`):** this helper was previously public +/// and is retained as a public, source-compatible entry point for downstream +/// native callers (e.g. `rs-platform-wallet`) that already depend on it. It +/// dispatches between create and replace based on the document's revision +/// and supports the legacy `document_state_transition_entropy = None` +/// fallback (RNG-derived entropy + id auto-rewrite) on the create branch. /// -/// This is intentionally **not** part of the public API. It is the -/// pre-broadcast core shared by [`build_signed_document_create_transition`], -/// [`build_signed_document_replace_transition`], and the legacy -/// [`PutDocument::put_to_platform`] trait method. It allocates a fresh -/// identity-contract nonce, picks the create-vs-replace branch based on the -/// document's revision, falls back to RNG-derived entropy + id auto-rewrite on -/// the create branch when `document_state_transition_entropy` is `None`, -/// applies `user_fee_increase` / `token_payment_info` / -/// `state_transition_creation_options` from `settings`, signs the transition, -/// and runs structure validation. +/// **New callers should prefer the strict helpers** +/// [`build_signed_document_create_transition`] / +/// [`build_signed_document_replace_transition`] for fail-fast intent and +/// document-id-matches-entropy checks — this dispatch helper only rejects +/// the always-invalid `Some(0)` revision and does not enforce the strict +/// id-matches-entropy invariant by itself. The strict helpers run their +/// validation **before** any nonce allocation. /// -/// # Why this is not public +/// # Behavior /// -/// The auto-fallback create branch (entropy `None` → RNG entropy + rewritten -/// document id) is convenient for the legacy `PutDocument` trait but is a -/// footgun for prepare/sign-without-broadcast flows like the wasm-sdk's -/// `prepareDocumentCreate`, where the caller's already-derived document id -/// must commit to the entropy they pass in. Public callers must go through -/// [`build_signed_document_create_transition`] (which enforces the strict -/// id-matches-entropy check) or [`build_signed_document_replace_transition`]. +/// Allocates a fresh identity-contract nonce, picks the create-vs-replace +/// branch based on the document's revision, falls back to RNG-derived +/// entropy + id auto-rewrite on the create branch when +/// `document_state_transition_entropy` is `None`, applies +/// `user_fee_increase` / `token_payment_info` / +/// `state_transition_creation_options` from `settings`, signs the +/// transition, and runs structure validation. /// /// # Revision validation /// @@ -221,7 +238,7 @@ pub trait PutDocument>: Waitable { /// the cache entry if it still equals the nonce allocated by this attempt, so /// concurrent allocations are not clobbered. #[allow(clippy::too_many_arguments)] -async fn build_signed_document_create_or_replace_transition>( +pub async fn build_signed_document_create_or_replace_transition>( sdk: &Sdk, document: &Document, document_type: &DocumentType, @@ -428,9 +445,19 @@ impl> PutDocument for Document { /// auto-generates 32-byte entropy + rewrites `document.id` via /// [`Document::generate_document_id_v0`] before signing. This preserves /// the original `PutDocument` behavior used by in-tree callers such as - /// `rs-platform-wallet` profile creation. New prepare/sign-without-broadcast - /// call sites should use the strict create/replace builders so - /// the supplied document id and entropy commit to the same value. + /// `rs-platform-wallet` profile creation. + /// + /// When `document_state_transition_entropy = Some(entropy)` on the + /// create path the call now locally rejects (before any nonce + /// allocation) if the entropy does not derive `document.id` via + /// [`Document::generate_document_id_v0`] — the strict create helper + /// that backs this routing enforces the id-matches-entropy invariant. + /// `None` still auto-generates entropy and rewrites the document id + /// for legacy callers that opt into that behavior. + /// + /// New prepare/sign-without-broadcast call sites should use the strict + /// create/replace builders so the supplied document id and entropy + /// commit to the same value. async fn put_to_platform( &self, sdk: &Sdk, diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index b7d9f2446f0..14d2d821726 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -503,6 +503,23 @@ impl Sdk { /// [`refresh_identity_nonce`](Self::refresh_identity_nonce), which /// preserves the cached (bumped) value so the cache cannot regress past /// a nonce the network may have accepted. + /// + /// # Scope: identity-contract nonces only + /// + /// This method is intentionally scoped to **identity-contract nonces** + /// — the nonces allocated by [`get_identity_contract_nonce`] for + /// `(identity_id, contract_id)` pairs. Today all batch document + /// transitions use identity-contract nonces, so the existing document + /// prepare/sign-without-broadcast APIs all roll back through this + /// single entry point. + /// + /// Plain identity nonces (allocated via the identity-nonce path used by + /// non-batch identity state transitions) are tracked in a separate + /// cache and are **not** rolled back here. If/when prepare APIs are + /// added for transitions that consume a plain identity nonce, a + /// sibling `rollback_identity_nonce` must be introduced first; + /// only then should those new prepare APIs adopt the same + /// allocate / sign / validate / rollback pattern this method enables. pub async fn rollback_identity_contract_nonce( &self, identity_id: Identifier, diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 8edc08499b9..61a7113a349 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -64,14 +64,15 @@ use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; -use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; +use dash_sdk::platform::documents::transitions::{ + build_signed_document_delete_transition, DocumentDeleteTransitionBuilder, +}; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::{ build_signed_document_create_transition, build_signed_document_replace_transition, PutDocument, }; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; -use dash_sdk::platform::transition::validation::ensure_valid_state_transition_structure; use js_sys::Reflect; use std::sync::Arc; use wasm_bindgen::{prelude::*, JsCast}; @@ -907,46 +908,16 @@ impl WasmSdk { builder }; - // Pre-allocate the identity-contract nonce so that any pre-broadcast - // failure (sign or local structure validation) can be rolled back via - // rs-sdk's `rollback_identity_contract_nonce`. The rollback is - // conditional: it only adjusts the cache entry if it still equals the - // nonce allocated here, so it does not clobber concurrent allocations. - let allocated_nonce = self - .inner_sdk() - .get_identity_contract_nonce(owner_id, contract_id, true, settings) - .await?; - - let state_transition = match builder - .sign_with_nonce( - allocated_nonce, - &identity_key, - &signer, - self.inner_sdk().version(), - ) - .await - { - Ok(st) => st, - Err(err) => { - self.inner_sdk() - .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) - .await; - return Err(err.into()); - } - }; - - // Validate structure before handing the ST back, mirroring rs-sdk's - // pre-broadcast check. For document Batch transitions this currently - // ends up as a no-op because DPP returns UnsupportedFeatureError until - // that structure validation is implemented there. - if let Err(err) = - ensure_valid_state_transition_structure(&state_transition, self.inner_sdk().version()) - { - self.inner_sdk() - .rollback_identity_contract_nonce(owner_id, contract_id, allocated_nonce) - .await; - return Err(err.into()); - } + // Delegate the nonce-allocate / sign / structure-validate / rollback + // sequence to rs-sdk's shared helper so wasm-sdk and FFI share the + // single implementation. + let state_transition = build_signed_document_delete_transition( + self.inner_sdk(), + &builder, + &identity_key, + &signer, + ) + .await?; Ok(state_transition.into()) } @@ -1379,6 +1350,7 @@ mod tests { use dash_sdk::dpp::state_transition::identity_credit_transfer_transition::IdentityCreditTransferTransition; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::transition::validation::ensure_valid_state_transition_structure; #[test] fn create_revision_guard_accepts_none_and_initial_revision() { From 3ba4b982260ca78f554f85595fb4062637117415 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 12 May 2026 01:42:18 -0500 Subject: [PATCH 17/24] fix(rs-sdk): address document prepare review findings --- packages/rs-sdk-ffi/src/error.rs | 8 + packages/rs-sdk/src/error.rs | 6 + .../platform/documents/transitions/delete.rs | 250 +++++++++++++++++- .../src/platform/transition/put_document.rs | 87 ++++-- packages/wasm-sdk/src/error.rs | 3 + 5 files changed, 323 insertions(+), 31 deletions(-) diff --git a/packages/rs-sdk-ffi/src/error.rs b/packages/rs-sdk-ffi/src/error.rs index d4788dff542..f774f9c2ebe 100644 --- a/packages/rs-sdk-ffi/src/error.rs +++ b/packages/rs-sdk-ffi/src/error.rs @@ -102,6 +102,14 @@ impl From for DashSDKError { let (code, message) = match &err { FFIError::InvalidParameter(_) => (DashSDKErrorCode::InvalidParameter, err.to_string()), FFIError::SDKError(sdk_err) => { + // Typed dispatch wins over message sniffing. + if matches!(sdk_err, dash_sdk::Error::InvalidArgument(_)) { + return DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + sdk_err.to_string(), + ); + } + // Extract more detailed error information let error_str = sdk_err.to_string(); diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 714538dbc64..92eba33d3b6 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -96,6 +96,12 @@ pub enum Error { #[error("SDK error: {0}")] Generic(String), + /// Caller-provided input failed local validation before any network or + /// nonce-affecting work. Maps to `WasmSdkErrorKind::InvalidArgument` in + /// the wasm-sdk and `DashSDKErrorCode::InvalidParameter` in the FFI. + #[error("Invalid argument: {0}")] + InvalidArgument(String), + /// Context provider error #[error("Context provider error: {0}")] ContextProviderError(#[from] ContextProviderError), diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 5b94bea2214..6209cd626f5 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -101,7 +101,14 @@ impl DocumentDeleteTransitionBuilder { self } - /// Adds a user fee increase to the document delete transition + /// Adds a user fee increase to the document delete transition. + /// + /// Explicit setters always win regardless of call order. If + /// [`Self::with_settings`] has already stored a [`PutSettings`] on this + /// builder, its `user_fee_increase` is updated in place so + /// `self.settings` remains the single source of truth for the + /// effective value used at sign/broadcast time. Other [`PutSettings`] + /// fields are preserved. /// /// # Arguments /// @@ -112,6 +119,9 @@ impl DocumentDeleteTransitionBuilder { /// * `Self` - The updated builder pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self { self.user_fee_increase = Some(user_fee_increase); + if let Some(settings) = self.settings.as_mut() { + settings.user_fee_increase = Some(user_fee_increase); + } self } @@ -125,6 +135,14 @@ impl DocumentDeleteTransitionBuilder { /// `None`. This makes the builder order-independent for these two /// fields and avoids silently clobbering a deliberate caller choice. /// + /// After this call, the stored [`PutSettings`] reflects the + /// **effective** values for `user_fee_increase` and + /// `state_transition_creation_options` — i.e. whichever value will + /// actually be applied at sign time — so `self.settings` and the + /// dedicated builder fields do not disagree. Other [`PutSettings`] + /// fields (timeouts, retry behavior, etc.) are preserved from the + /// supplied `settings`. + /// /// # Arguments /// /// * `settings` - The settings to add @@ -145,11 +163,27 @@ impl DocumentDeleteTransitionBuilder { self.state_transition_creation_options = Some(state_transition_creation_options); } } - self.settings = Some(settings); + // Merge effective values back into the stored settings so there is + // exactly one source of truth for `user_fee_increase` and + // `state_transition_creation_options`. Other PutSettings fields + // (timeouts, retry behavior, etc.) are preserved from the supplied + // `settings`. + let mut effective_settings = settings; + effective_settings.user_fee_increase = self.user_fee_increase; + effective_settings.state_transition_creation_options = + self.state_transition_creation_options; + self.settings = Some(effective_settings); self } - /// Adds creation_options to the document delete transition + /// Adds creation_options to the document delete transition. + /// + /// Explicit setters always win regardless of call order. If + /// [`Self::with_settings`] has already stored a [`PutSettings`] on this + /// builder, its `state_transition_creation_options` is updated in + /// place so `self.settings` remains the single source of truth for + /// the effective value used at sign/broadcast time. Other + /// [`PutSettings`] fields are preserved. /// /// # Arguments /// @@ -163,6 +197,9 @@ impl DocumentDeleteTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + if let Some(settings) = self.settings.as_mut() { + settings.state_transition_creation_options = Some(creation_options); + } self } @@ -479,21 +516,222 @@ mod tests { builder.state_transition_creation_options, Some(explicit_creation_options) ); - assert!(builder.settings.is_some()); + // After `with_settings`, the stored settings must reflect the + // *effective* values (explicit fields win), so the two sources of + // truth agree. assert_eq!( builder .settings .as_ref() .and_then(|settings| settings.user_fee_increase), - None + Some(42) ); assert_eq!( builder .settings .as_ref() .and_then(|settings| settings.state_transition_creation_options), - None + Some(explicit_creation_options) + ); + } + + /// When explicit setters have been used, the stored settings must + /// reflect those explicit values rather than whatever was passed via + /// `with_settings`. This guarantees a single source of truth for + /// `user_fee_increase` and `state_transition_creation_options`. + #[test] + fn with_settings_stores_effective_explicit_values() { + let explicit_options = StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }; + let settings_options = StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }; + let settings = PutSettings { + user_fee_increase: Some(7), + state_transition_creation_options: Some(settings_options), + ..Default::default() + }; + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_user_fee_increase(42) + .with_state_transition_creation_options(explicit_options) + .with_settings(settings); + + // Explicit setters won. + assert_eq!(builder.user_fee_increase, Some(42)); + assert_eq!( + builder.state_transition_creation_options, + Some(explicit_options) + ); + // The stored settings reflect the same effective values. + let stored = builder.settings.expect("settings must be stored"); + assert_eq!(stored.user_fee_increase, Some(42)); + assert_eq!( + stored.state_transition_creation_options, + Some(explicit_options) + ); + } + + /// Settings-first-then-explicit ordering: a later + /// [`DocumentDeleteTransitionBuilder::with_user_fee_increase`] call + /// must update both the dedicated field and the stored + /// [`PutSettings::user_fee_increase`] so the two never disagree. + #[test] + fn explicit_user_fee_increase_after_settings_updates_stored_settings() { + let settings = PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }; + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_settings(settings) + .with_user_fee_increase(42); + + assert_eq!(builder.user_fee_increase, Some(42)); + let stored = builder.settings.as_ref().expect("settings must be stored"); + assert_eq!( + stored.user_fee_increase, + Some(42), + "stored settings must reflect the later explicit user_fee_increase", + ); + } + + /// Settings-first-then-explicit ordering: a later + /// [`DocumentDeleteTransitionBuilder::with_state_transition_creation_options`] + /// call must update both the dedicated field and the stored + /// [`PutSettings::state_transition_creation_options`] so the two + /// never disagree. + #[test] + fn explicit_creation_options_after_settings_updates_stored_settings() { + let settings_options = StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }; + let explicit_options = StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }; + assert_ne!( + explicit_options, settings_options, + "test precondition: explicit and settings options must differ to prove which one wins" + ); + let settings = PutSettings { + state_transition_creation_options: Some(settings_options), + ..Default::default() + }; + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_settings(settings) + .with_state_transition_creation_options(explicit_options); + + assert_eq!( + builder.state_transition_creation_options, + Some(explicit_options), + ); + let stored = builder.settings.as_ref().expect("settings must be stored"); + assert_eq!( + stored.state_transition_creation_options, + Some(explicit_options), + "stored settings must reflect the later explicit creation_options", + ); + } + + /// Settings-first-then-explicit must preserve unrelated + /// [`PutSettings`] fields supplied by the earlier `with_settings` + /// call (e.g. timeouts, retry behavior). Only the field touched by + /// the explicit setter should change. + #[test] + fn explicit_setters_after_settings_preserve_other_put_settings_fields() { + let original_settings = PutSettings { + user_fee_increase: Some(7), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }), + identity_nonce_stale_time_s: Some(123), + wait_timeout: Some(std::time::Duration::from_secs(45)), + ..Default::default() + }; + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + let explicit_options = StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }; + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_settings(original_settings.clone()) + .with_user_fee_increase(42) + .with_state_transition_creation_options(explicit_options); + + let stored = builder.settings.expect("settings must be stored"); + // The two fields the explicit setters target must reflect the + // explicit values. + assert_eq!(stored.user_fee_increase, Some(42)); + assert_eq!( + stored.state_transition_creation_options, + Some(explicit_options) + ); + // Every other PutSettings field must be preserved exactly as it + // was provided to `with_settings`. + assert_eq!( + stored.identity_nonce_stale_time_s, + original_settings.identity_nonce_stale_time_s ); + assert_eq!(stored.wait_timeout, original_settings.wait_timeout); } /// Failing-signer used by the rollback test below to deterministically diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 68eba966202..0ce53f54c1b 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -62,8 +62,8 @@ fn is_document_replace_revision(revision: Option) -> bool { /// `0` is always invalid regardless of caller intent. fn ensure_revision_nonzero(revision: Option) -> Result<(), Error> { if matches!(revision, Some(0)) { - return Err(Error::Generic( - "InvalidArgument: document revision 0 is invalid; \ + return Err(Error::InvalidArgument( + "document revision 0 is invalid; \ use unset or 1 (INITIAL_REVISION) for create, or > 1 for replace" .to_string(), )); @@ -80,8 +80,8 @@ fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { match revision { None => Ok(()), Some(rev) if rev == INITIAL_REVISION => Ok(()), - Some(rev) => Err(Error::Generic(format!( - "InvalidArgument: document revision is {rev} but create requires revision \ + Some(rev) => Err(Error::InvalidArgument(format!( + "document revision is {rev} but create requires revision \ to be unset or {INITIAL_REVISION}; use the replace path for revisions > {INITIAL_REVISION}" ))), } @@ -95,12 +95,12 @@ fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { match revision { Some(rev) if rev > INITIAL_REVISION => Ok(()), - Some(rev) => Err(Error::Generic(format!( - "InvalidArgument: document revision is {rev} but replace requires revision > \ + Some(rev) => Err(Error::InvalidArgument(format!( + "document revision is {rev} but replace requires revision > \ {INITIAL_REVISION}; use the create path for new documents" ))), - None => Err(Error::Generic( - "InvalidArgument: document must have a revision set for replace; \ + None => Err(Error::InvalidArgument( + "document must have a revision set for replace; \ use the create path for new documents" .to_string(), )), @@ -128,8 +128,8 @@ fn ensure_document_id_matches_entropy( entropy.as_slice(), ); if document.id() != expected { - return Err(Error::Generic(format!( - "InvalidArgument: document.id does not match \ + return Err(Error::InvalidArgument(format!( + "document.id does not match \ generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \ expected {expected}, got {got}. \ Either set document.id to the derived value before calling \ @@ -197,12 +197,12 @@ pub trait PutDocument>: Waitable { /// Legacy create-or-replace dispatch: build, sign, and structurally validate /// a document [`StateTransition`] without broadcasting it. /// -/// **Compatibility note (kept `pub`):** this helper was previously public -/// and is retained as a public, source-compatible entry point for downstream -/// native callers (e.g. `rs-platform-wallet`) that already depend on it. It -/// dispatches between create and replace based on the document's revision -/// and supports the legacy `document_state_transition_entropy = None` -/// fallback (RNG-derived entropy + id auto-rewrite) on the create branch. +/// **Legacy / source-compatible only.** This helper is retained as a +/// public, source-compatible entry point for native callers that already +/// depend on it. It dispatches between create and replace based on the +/// document's revision and supports the legacy +/// `document_state_transition_entropy = None` fallback (RNG-derived +/// entropy + id auto-rewrite) on the create branch. /// /// **New callers should prefer the strict helpers** /// [`build_signed_document_create_transition`] / @@ -238,6 +238,9 @@ pub trait PutDocument>: Waitable { /// the cache entry if it still equals the nonce allocated by this attempt, so /// concurrent allocations are not clobbered. #[allow(clippy::too_many_arguments)] +#[deprecated( + note = "use build_signed_document_create_transition or build_signed_document_replace_transition for strict intent validation" +)] pub async fn build_signed_document_create_or_replace_transition>( sdk: &Sdk, document: &Document, @@ -247,6 +250,34 @@ pub async fn build_signed_document_create_or_replace_transition, signer: &S, settings: Option, +) -> Result { + build_signed_document_create_or_replace_transition_legacy( + sdk, + document, + document_type, + document_state_transition_entropy, + identity_public_key, + token_payment_info, + signer, + settings, + ) + .await +} + +/// Private implementation backing the deprecated public legacy dispatcher. +/// +/// Internal strict helpers route through this private entry point so +/// in-tree call sites do not trigger the public deprecation warning. +#[allow(clippy::too_many_arguments)] +async fn build_signed_document_create_or_replace_transition_legacy>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, ) -> Result { // Reject the always-invalid `Some(0)` revision before allocating any // nonce. Strict create/replace intent validation is the job of the @@ -330,7 +361,7 @@ pub async fn build_signed_document_create_transition, ) -> Result { ensure_revision_for_replace(document.revision())?; - build_signed_document_create_or_replace_transition( + build_signed_document_create_or_replace_transition_legacy( sdk, document, document_type, @@ -626,8 +657,8 @@ mod tests { assert!(ensure_revision_nonzero(Some(u64::MAX)).is_ok()); let err = ensure_revision_nonzero(Some(0)).expect_err("revision 0 must error"); + assert!(matches!(err, Error::InvalidArgument(_)), "err: {err:?}"); let msg = err.to_string(); - assert!(msg.contains("InvalidArgument"), "msg: {msg}"); assert!(msg.contains("revision 0"), "msg: {msg}"); } @@ -640,12 +671,12 @@ mod tests { #[test] fn ensure_revision_for_create_rejects_zero_and_above_initial() { let zero = ensure_revision_for_create(Some(0)).expect_err("revision 0 must error"); - assert!(zero.to_string().contains("InvalidArgument")); + assert!(matches!(zero, Error::InvalidArgument(_)), "err: {zero:?}"); assert!(zero.to_string().contains("create requires revision")); let above = ensure_revision_for_create(Some(INITIAL_REVISION + 1)) .expect_err("revision > INITIAL_REVISION must error on create path"); - assert!(above.to_string().contains("InvalidArgument")); + assert!(matches!(above, Error::InvalidArgument(_)), "err: {above:?}"); assert!(above.to_string().contains("replace path")); } @@ -658,17 +689,23 @@ mod tests { #[test] fn ensure_revision_for_replace_rejects_missing_zero_and_initial_revision() { let missing = ensure_revision_for_replace(None).expect_err("missing revision must error"); - assert!(missing.to_string().contains("InvalidArgument")); + assert!( + matches!(missing, Error::InvalidArgument(_)), + "err: {missing:?}" + ); assert!(missing.to_string().contains("must have a revision set")); let zero = ensure_revision_for_replace(Some(0)).expect_err("revision 0 must error on replace"); - assert!(zero.to_string().contains("InvalidArgument")); + assert!(matches!(zero, Error::InvalidArgument(_)), "err: {zero:?}"); assert!(zero.to_string().contains("replace requires revision")); let initial = ensure_revision_for_replace(Some(INITIAL_REVISION)) .expect_err("INITIAL_REVISION must error on replace path"); - assert!(initial.to_string().contains("InvalidArgument")); + assert!( + matches!(initial, Error::InvalidArgument(_)), + "err: {initial:?}" + ); assert!(initial.to_string().contains("replace requires revision")); } @@ -896,8 +933,8 @@ mod tests { .await .expect_err("id-mismatch must error before nonce allocation"); + assert!(matches!(err, Error::InvalidArgument(_)), "err: {err:?}"); let msg = err.to_string(); - assert!(msg.contains("InvalidArgument"), "msg: {msg}"); assert!( msg.contains("does not match"), "expected id-mismatch error, got: {msg}" diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index 6f41a1d76ea..a7249febd94 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -170,6 +170,9 @@ impl From for WasmSdkError { retriable, ), Generic(msg) => Self::new(WasmSdkErrorKind::Generic, msg, None, retriable), + InvalidArgument(msg) => { + Self::new(WasmSdkErrorKind::InvalidArgument, msg, None, retriable) + } ContextProviderError(e) => Self::new( WasmSdkErrorKind::ContextProviderError, e.to_string(), From ff2285b4a3589f278d4244df42b275be988fa89c Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 12 May 2026 09:17:15 -0500 Subject: [PATCH 18/24] fix(rs-sdk): roll back document builder nonces --- .../platform/documents/transitions/create.rs | 60 +++++- .../platform/documents/transitions/delete.rs | 189 +++++++----------- .../platform/documents/transitions/replace.rs | 60 +++++- .../src/platform/transition/put_document.rs | 83 +++++++- 4 files changed, 264 insertions(+), 128 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index bfcb7bc43be..09235312b59 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -6,6 +6,7 @@ use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; +use dpp::prelude::IdentityNonce; use dpp::prelude::UserFeeIncrease; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; @@ -120,7 +121,16 @@ impl DocumentCreateTransitionBuilder { self } - /// Signs the document create transition + /// Signs the document create transition. + /// + /// Allocates a fresh identity-contract nonce from `sdk` and delegates to + /// [`Self::sign_with_nonce`]. If signing fails *after* the nonce has been + /// allocated (e.g. the document type lookup or BatchTransition build + /// fails), the bumped identity-contract nonce is conditionally rolled + /// back via + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) + /// so the local cache does not advance past a nonce the network never + /// observed. /// /// # Arguments /// @@ -139,15 +149,51 @@ impl DocumentCreateTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { + let owner_id = self.document.owner_id(); + let contract_id = self.data_contract.id(); let identity_contract_nonce = sdk - .get_identity_contract_nonce( - self.document.owner_id(), - self.data_contract.id(), - true, - self.settings, - ) + .get_identity_contract_nonce(owner_id, contract_id, true, self.settings) .await?; + match self + .sign_with_nonce( + identity_contract_nonce, + identity_public_key, + signer, + platform_version, + ) + .await + { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce( + owner_id, + contract_id, + identity_contract_nonce, + ) + .await; + Err(err) + } + } + } + + /// Signs the document create transition using a pre-allocated + /// identity-contract nonce. + /// + /// This variant lets the caller separate nonce allocation from signing so + /// pre-broadcast failures can be rolled back by calling + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) + /// with the same `identity_contract_nonce`. The caller is responsible for + /// having obtained the nonce via + /// [`Sdk::get_identity_contract_nonce`](crate::Sdk::get_identity_contract_nonce) + /// with `bump_first = true` for the same `(owner_id, contract_id)` pair. + pub async fn sign_with_nonce( + &self, + identity_contract_nonce: IdentityNonce, + identity_public_key: &IdentityPublicKey, + signer: &impl Signer, + platform_version: &PlatformVersion, + ) -> Result { let document_type = self .data_contract .document_type_for_name(&self.document_type_name) diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 6209cd626f5..7d82d774945 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -103,12 +103,10 @@ impl DocumentDeleteTransitionBuilder { /// Adds a user fee increase to the document delete transition. /// - /// Explicit setters always win regardless of call order. If - /// [`Self::with_settings`] has already stored a [`PutSettings`] on this - /// builder, its `user_fee_increase` is updated in place so - /// `self.settings` remains the single source of truth for the - /// effective value used at sign/broadcast time. Other [`PutSettings`] - /// fields are preserved. + /// The dedicated [`Self::user_fee_increase`] field is the single source + /// of truth for the effective value applied at sign time. Explicit + /// setters always win regardless of call order — see + /// [`Self::with_settings`] for the order-independence contract. /// /// # Arguments /// @@ -119,29 +117,24 @@ impl DocumentDeleteTransitionBuilder { /// * `Self` - The updated builder pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self { self.user_fee_increase = Some(user_fee_increase); - if let Some(settings) = self.settings.as_mut() { - settings.user_fee_increase = Some(user_fee_increase); - } self } /// Adds settings to the document delete transition. /// - /// Explicit setters always win regardless of call order: if - /// [`Self::with_user_fee_increase`] or - /// [`Self::with_state_transition_creation_options`] has already been - /// called on this builder, the corresponding field in `settings` is - /// only used as a fallback when the dedicated builder field is still - /// `None`. This makes the builder order-independent for these two - /// fields and avoids silently clobbering a deliberate caller choice. - /// - /// After this call, the stored [`PutSettings`] reflects the - /// **effective** values for `user_fee_increase` and - /// `state_transition_creation_options` — i.e. whichever value will - /// actually be applied at sign time — so `self.settings` and the - /// dedicated builder fields do not disagree. Other [`PutSettings`] - /// fields (timeouts, retry behavior, etc.) are preserved from the - /// supplied `settings`. + /// `user_fee_increase` and `state_transition_creation_options` are owned + /// by their dedicated builder fields, which are the single source of + /// truth for the effective values applied at sign time. This method + /// extracts those two fields out of the supplied [`PutSettings`] into + /// the dedicated fields **only if** the dedicated field is still + /// `None`, and then stores the remainder of `settings` (with those two + /// fields cleared) on the builder. + /// + /// Net effect: explicit setters always win over `with_settings` for + /// `user_fee_increase` and `state_transition_creation_options`, regardless + /// of call order. All other [`PutSettings`] fields (timeouts, retry + /// behavior, nonce stale time, etc.) flow through unchanged to be used + /// for nonce allocation and broadcast. /// /// # Arguments /// @@ -150,40 +143,28 @@ impl DocumentDeleteTransitionBuilder { /// # Returns /// /// * `Self` - The updated builder - pub fn with_settings(mut self, settings: PutSettings) -> Self { + pub fn with_settings(mut self, mut settings: PutSettings) -> Self { if self.user_fee_increase.is_none() { - if let Some(user_fee_increase) = settings.user_fee_increase { - self.user_fee_increase = Some(user_fee_increase); - } + self.user_fee_increase = settings.user_fee_increase; } if self.state_transition_creation_options.is_none() { - if let Some(state_transition_creation_options) = - settings.state_transition_creation_options - { - self.state_transition_creation_options = Some(state_transition_creation_options); - } + self.state_transition_creation_options = settings.state_transition_creation_options; } - // Merge effective values back into the stored settings so there is - // exactly one source of truth for `user_fee_increase` and - // `state_transition_creation_options`. Other PutSettings fields - // (timeouts, retry behavior, etc.) are preserved from the supplied - // `settings`. - let mut effective_settings = settings; - effective_settings.user_fee_increase = self.user_fee_increase; - effective_settings.state_transition_creation_options = - self.state_transition_creation_options; - self.settings = Some(effective_settings); + // Strip the fee/creation-options fields from the stored settings so + // the dedicated builder fields are the sole source of truth at + // sign time. The remainder of `settings` flows through unchanged. + settings.user_fee_increase = None; + settings.state_transition_creation_options = None; + self.settings = Some(settings); self } /// Adds creation_options to the document delete transition. /// - /// Explicit setters always win regardless of call order. If - /// [`Self::with_settings`] has already stored a [`PutSettings`] on this - /// builder, its `state_transition_creation_options` is updated in - /// place so `self.settings` remains the single source of truth for - /// the effective value used at sign/broadcast time. Other - /// [`PutSettings`] fields are preserved. + /// The dedicated [`Self::state_transition_creation_options`] field is the + /// single source of truth for the effective value applied at sign time. + /// Explicit setters always win regardless of call order — see + /// [`Self::with_settings`] for the order-independence contract. /// /// # Arguments /// @@ -197,9 +178,6 @@ impl DocumentDeleteTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); - if let Some(settings) = self.settings.as_mut() { - settings.state_transition_creation_options = Some(creation_options); - } self } @@ -381,7 +359,7 @@ mod tests { use dpp::version::PlatformVersion; #[test] - fn with_settings_propagates_signed_transition_fields() { + fn with_settings_extracts_fee_and_options_into_dedicated_fields() { let settings = PutSettings { user_fee_increase: Some(42), state_transition_creation_options: Some(StateTransitionCreationOptions::default()), @@ -402,15 +380,17 @@ mod tests { ) .with_settings(settings); - assert_eq!( - builder.settings.map(|settings| settings.user_fee_increase), - Some(settings.user_fee_increase) - ); + // The dedicated fields are the single source of truth. assert_eq!(builder.user_fee_increase, settings.user_fee_increase); assert_eq!( builder.state_transition_creation_options, settings.state_transition_creation_options ); + // The stored settings have the two fee/options fields cleared so + // the dedicated fields are the sole source of truth at sign time. + let stored = builder.settings.expect("settings must be stored"); + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); } #[test] @@ -511,36 +491,26 @@ mod tests { ..Default::default() }); + // The dedicated fields remain the source of truth. assert_eq!(builder.user_fee_increase, Some(42)); assert_eq!( builder.state_transition_creation_options, Some(explicit_creation_options) ); - // After `with_settings`, the stored settings must reflect the - // *effective* values (explicit fields win), so the two sources of - // truth agree. - assert_eq!( - builder - .settings - .as_ref() - .and_then(|settings| settings.user_fee_increase), - Some(42) - ); - assert_eq!( - builder - .settings - .as_ref() - .and_then(|settings| settings.state_transition_creation_options), - Some(explicit_creation_options) - ); + // Stored settings always has the two fee/options fields cleared — + // they live on the dedicated builder fields, not on the stored + // settings. + let stored = builder.settings.expect("settings must be stored"); + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); } - /// When explicit setters have been used, the stored settings must - /// reflect those explicit values rather than whatever was passed via - /// `with_settings`. This guarantees a single source of truth for - /// `user_fee_increase` and `state_transition_creation_options`. + /// When explicit setters have been used before `with_settings`, the + /// dedicated fields are the single source of truth — the settings + /// values for those two fields are dropped on the floor and the stored + /// settings have them cleared. #[test] - fn with_settings_stores_effective_explicit_values() { + fn explicit_setters_before_settings_keep_dedicated_fields_authoritative() { let explicit_options = StateTransitionCreationOptions { batch_feature_version: Some(2), ..Default::default() @@ -579,21 +549,20 @@ mod tests { builder.state_transition_creation_options, Some(explicit_options) ); - // The stored settings reflect the same effective values. + // Stored settings has those two fields cleared — the dedicated + // builder fields are the sole source of truth. let stored = builder.settings.expect("settings must be stored"); - assert_eq!(stored.user_fee_increase, Some(42)); - assert_eq!( - stored.state_transition_creation_options, - Some(explicit_options) - ); + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); } /// Settings-first-then-explicit ordering: a later /// [`DocumentDeleteTransitionBuilder::with_user_fee_increase`] call - /// must update both the dedicated field and the stored - /// [`PutSettings::user_fee_increase`] so the two never disagree. + /// updates the dedicated field. The stored settings has the field + /// cleared (it has only ever stored "other" PutSettings fields after + /// the settings-extract step), so there is one source of truth. #[test] - fn explicit_user_fee_increase_after_settings_updates_stored_settings() { + fn explicit_user_fee_increase_after_settings_updates_dedicated_field() { let settings = PutSettings { user_fee_increase: Some(7), ..Default::default() @@ -618,20 +587,17 @@ mod tests { assert_eq!(builder.user_fee_increase, Some(42)); let stored = builder.settings.as_ref().expect("settings must be stored"); - assert_eq!( - stored.user_fee_increase, - Some(42), - "stored settings must reflect the later explicit user_fee_increase", - ); + // Stored settings has the user_fee_increase field cleared — it + // never carries the fee/options fields after with_settings. + assert_eq!(stored.user_fee_increase, None); } /// Settings-first-then-explicit ordering: a later /// [`DocumentDeleteTransitionBuilder::with_state_transition_creation_options`] - /// call must update both the dedicated field and the stored - /// [`PutSettings::state_transition_creation_options`] so the two - /// never disagree. + /// call updates the dedicated field. The stored settings has the + /// field cleared. #[test] - fn explicit_creation_options_after_settings_updates_stored_settings() { + fn explicit_creation_options_after_settings_updates_dedicated_field() { let settings_options = StateTransitionCreationOptions { batch_feature_version: Some(7), ..Default::default() @@ -671,19 +637,15 @@ mod tests { Some(explicit_options), ); let stored = builder.settings.as_ref().expect("settings must be stored"); - assert_eq!( - stored.state_transition_creation_options, - Some(explicit_options), - "stored settings must reflect the later explicit creation_options", - ); + assert_eq!(stored.state_transition_creation_options, None); } - /// Settings-first-then-explicit must preserve unrelated - /// [`PutSettings`] fields supplied by the earlier `with_settings` - /// call (e.g. timeouts, retry behavior). Only the field touched by - /// the explicit setter should change. + /// `with_settings` strips `user_fee_increase` and + /// `state_transition_creation_options` out of the stored settings and + /// preserves every other [`PutSettings`] field exactly as supplied + /// (timeouts, retry behavior, nonce stale time, etc.). #[test] - fn explicit_setters_after_settings_preserve_other_put_settings_fields() { + fn with_settings_preserves_other_put_settings_fields() { let original_settings = PutSettings { user_fee_increase: Some(7), state_transition_creation_options: Some(StateTransitionCreationOptions { @@ -717,14 +679,17 @@ mod tests { .with_user_fee_increase(42) .with_state_transition_creation_options(explicit_options); - let stored = builder.settings.expect("settings must be stored"); - // The two fields the explicit setters target must reflect the - // explicit values. - assert_eq!(stored.user_fee_increase, Some(42)); + // The dedicated fields reflect the explicit values. + assert_eq!(builder.user_fee_increase, Some(42)); assert_eq!( - stored.state_transition_creation_options, + builder.state_transition_creation_options, Some(explicit_options) ); + let stored = builder.settings.expect("settings must be stored"); + // Stored settings always has fee/options cleared — sign-time + // reads from the dedicated fields. + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); // Every other PutSettings field must be preserved exactly as it // was provided to `with_settings`. assert_eq!( diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index b40d27c15ea..d4cf789e869 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -6,6 +6,7 @@ use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; +use dpp::prelude::IdentityNonce; use dpp::prelude::UserFeeIncrease; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; @@ -115,7 +116,16 @@ impl DocumentReplaceTransitionBuilder { self } - /// Signs the document replace transition + /// Signs the document replace transition. + /// + /// Allocates a fresh identity-contract nonce from `sdk` and delegates to + /// [`Self::sign_with_nonce`]. If signing fails *after* the nonce has been + /// allocated (e.g. the document type lookup or BatchTransition build + /// fails), the bumped identity-contract nonce is conditionally rolled + /// back via + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) + /// so the local cache does not advance past a nonce the network never + /// observed. /// /// # Arguments /// @@ -134,15 +144,51 @@ impl DocumentReplaceTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { + let owner_id = self.document.owner_id(); + let contract_id = self.data_contract.id(); let identity_contract_nonce = sdk - .get_identity_contract_nonce( - self.document.owner_id(), - self.data_contract.id(), - true, - self.settings, - ) + .get_identity_contract_nonce(owner_id, contract_id, true, self.settings) .await?; + match self + .sign_with_nonce( + identity_contract_nonce, + identity_public_key, + signer, + platform_version, + ) + .await + { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce( + owner_id, + contract_id, + identity_contract_nonce, + ) + .await; + Err(err) + } + } + } + + /// Signs the document replace transition using a pre-allocated + /// identity-contract nonce. + /// + /// This variant lets the caller separate nonce allocation from signing so + /// pre-broadcast failures can be rolled back by calling + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) + /// with the same `identity_contract_nonce`. The caller is responsible for + /// having obtained the nonce via + /// [`Sdk::get_identity_contract_nonce`](crate::Sdk::get_identity_contract_nonce) + /// with `bump_first = true` for the same `(owner_id, contract_id)` pair. + pub async fn sign_with_nonce( + &self, + identity_contract_nonce: IdentityNonce, + identity_public_key: &IdentityPublicKey, + signer: &impl Signer, + platform_version: &PlatformVersion, + ) -> Result { let document_type = self .data_contract .document_type_for_name(&self.document_type_name) diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 0ce53f54c1b..f6aca55cb53 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -504,8 +504,14 @@ impl> PutDocument for Document { // `prepareDocumentCreate` / `prepareDocumentReplace` paths. The // dispatch is driven by the document revision: unset or // `INITIAL_REVISION` selects create; revisions strictly greater than - // `INITIAL_REVISION` select replace; `Some(0)` is rejected by the - // strict replace helper before any nonce allocation. + // `INITIAL_REVISION` select replace. + // + // Reject `Some(0)` up front with the dispatch-aware + // `ensure_revision_nonzero` message rather than letting it fall into + // the replace branch — the replace-helper message says "use the + // create path", which would be misleading for `put_to_platform` + // callers (they aren't picking a branch themselves). + ensure_revision_nonzero(self.revision())?; let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { // Create path. Preserve legacy behavior: when the caller did not // supply entropy, generate it and rewrite `document.id` so the @@ -858,6 +864,79 @@ mod tests { ); } + /// Pre-broadcast signing failure inside the strict replace helper must + /// roll the identity-contract nonce back so the cache does not advance + /// past a nonce the network never observed. Mirrors the create-side + /// rollback test above; asserting via "next allocation reuses the + /// rolled-back value" matches the rollback pattern in + /// `internal_cache::mod`. + #[tokio::test] + async fn build_signed_document_replace_rolls_back_nonce_on_signing_failure() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use drive_proof_verifier::types::IdentityContractNonceFetcher; + + let document_type = test_document_type(); + let contract_id = document_type.data_contract_id(); + // Replace requires revision > INITIAL_REVISION; the document id is + // not entropy-derived for the replace path, so any id works. + let owner_id = Identifier::from([7; 32]); + let document = test_document(Some(INITIAL_REVISION + 1), Identifier::from([3; 32])); + assert_eq!(document.owner_id(), owner_id); + + let identity_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0u8; 33]), + disabled_at: None, + }); + + let mut sdk = crate::Sdk::new_mock(); + sdk.mock() + .expect_fetch::( + (owner_id, contract_id), + Some(IdentityContractNonceFetcher(10u64)), + ) + .await + .expect("set IdentityContractNonceFetcher mock expectation"); + + let signer = AlwaysFailingSigner; + + let err = build_signed_document_replace_transition( + &sdk, + &document, + &document_type, + &identity_key, + None, + &signer, + None, + ) + .await + .expect_err("signer failure must surface so the helper can roll back the allocated nonce"); + + assert!( + err.to_string().contains("deliberate signing failure"), + "expected the signer's failure to surface, got: {err}" + ); + + // Cache was bumped from platform=10 to 11 during the failed attempt + // and then rolled back to 10. Re-allocating with bump_first=true + // must yield 11 again — proving the rolled-back nonce is reusable. + let next = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, None) + .await + .expect("nonce allocation must succeed after rollback"); + assert_eq!( + next, 11, + "rolled-back nonce should be reused by the next allocation" + ); + } + /// The strict create helper must reject a document whose id does not /// match the supplied entropy *before* it allocates an /// identity-contract nonce. The post-condition we assert is: From 214dde32ece75ece49e61354730e5409d8e178d1 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 13 May 2026 00:14:24 -0500 Subject: [PATCH 19/24] fix(rs-sdk): tighten document create validation --- packages/rs-sdk/README.md | 6 + .../platform/documents/transitions/create.rs | 82 +++++- .../platform/documents/transitions/replace.rs | 61 ++++- .../src/platform/transition/put_document.rs | 253 ++++++++++++++---- packages/wasm-sdk/README.md | 1 + .../src/state_transitions/document.rs | 56 +++- .../tests/unit/prepare-document.spec.ts | 20 ++ 7 files changed, 415 insertions(+), 64 deletions(-) diff --git a/packages/rs-sdk/README.md b/packages/rs-sdk/README.md index 7a2372e0d4e..bee848f418d 100644 --- a/packages/rs-sdk/README.md +++ b/packages/rs-sdk/README.md @@ -46,6 +46,12 @@ You can see examples of mocking in [mock_fetch.rs](tests/fetch/mock_fetch.rs) an You can find quick start example in `examples/` folder. Examples must be configured by setting constants. +## Compatibility notes + +- `DocumentDeleteTransitionBuilder::with_settings(settings).sign(...)` now honors `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Native rs-sdk callers that passed delete transition fee/creation options through `PutSettings` may now see those intended values take effect on the wire instead of being silently dropped. + + + You can also inspect tests in `tests/` folder for more detailed examples. Also refer to [Platform Explorer](https://github.com/dashpay/rs-platform-explorer/) which uses the SDK to execute various state transitions. diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 09235312b59..c891fa86171 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -1,5 +1,10 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; +use crate::platform::transition::put_document::{ + build_signed_document_create_transition, ensure_document_id_matches_entropy, + ensure_revision_for_create, +}; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::ensure_valid_state_transition_structure; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; @@ -149,6 +154,19 @@ impl DocumentCreateTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { + { + let document_type = self + .data_contract + .document_type_for_name(&self.document_type_name) + .map_err(|e| Error::Protocol(e.into()))?; + ensure_revision_for_create(self.document.revision())?; + ensure_document_id_matches_entropy( + &self.document, + document_type, + &self.document_state_transition_entropy, + )?; + } + let owner_id = self.document.owner_id(); let contract_id = self.data_contract.id(); let identity_contract_nonce = sdk @@ -199,6 +217,13 @@ impl DocumentCreateTransitionBuilder { .document_type_for_name(&self.document_type_name) .map_err(|e| Error::Protocol(e.into()))?; + ensure_revision_for_create(self.document.revision())?; + ensure_document_id_matches_entropy( + &self.document, + document_type, + &self.document_state_transition_entropy, + )?; + let state_transition = BatchTransition::new_document_creation_transition_from_document( self.document.clone(), document_type, @@ -213,6 +238,8 @@ impl DocumentCreateTransitionBuilder { ) .await?; + ensure_valid_state_transition_structure(&state_transition, platform_version)?; + Ok(state_transition) } } @@ -253,13 +280,56 @@ impl Sdk { signing_key: &IdentityPublicKey, signer: &S, ) -> Result { - let platform_version = self.version(); + // Destructure so we can move builder-owned fields (notably the + // `StateTransitionCreationOptions`, which is not necessarily Clone) + // into the effective settings without an extra copy. + let DocumentCreateTransitionBuilder { + data_contract, + document_type_name, + document, + document_state_transition_entropy, + token_payment_info, + settings, + user_fee_increase, + state_transition_creation_options, + } = create_document_transition_builder; - let put_settings = create_document_transition_builder.settings; + // Preserve broadcast-time settings (request_settings, wait_timeout, + // identity_nonce_stale_time_s) by keeping the original builder + // settings around for the broadcast call. The strict helper gets + // an `effective` clone that overlays the builder-specific + // user_fee_increase / state_transition_creation_options fields. + let broadcast_settings = settings; + let mut effective_settings = settings.unwrap_or_default(); + if let Some(ufi) = user_fee_increase { + effective_settings.user_fee_increase = Some(ufi); + } + if state_transition_creation_options.is_some() { + effective_settings.state_transition_creation_options = + state_transition_creation_options; + } - let state_transition = create_document_transition_builder - .sign(self, signing_key, signer, platform_version) - .await?; + // Resolve the owned document type from the contract. + let document_type = data_contract + .document_type_cloned_for_name(&document_type_name) + .map_err(|e| Error::Protocol(e.into()))?; + + // Route through the strict create helper so the one-shot + // `document_create` builder API gets the same fail-fast revision + // and id-matches-entropy validation as the wasm-sdk + // `prepareDocumentCreate` path. Pre-broadcast errors roll back the + // allocated identity-contract nonce inside the helper. + let state_transition = build_signed_document_create_transition( + self, + &document, + &document_type, + document_state_transition_entropy, + signing_key, + token_payment_info, + signer, + Some(effective_settings), + ) + .await?; // Low-level debug logging via tracing trace!("document_create: state transition created and signed"); @@ -267,7 +337,7 @@ impl Sdk { trace!(transition = ?state_transition, "document_create: transition details"); let proof_result = state_transition - .broadcast_and_wait::(self, put_settings) + .broadcast_and_wait::(self, broadcast_settings) .await?; match proof_result { diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index d4cf789e869..816c4b8d952 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -1,5 +1,9 @@ use crate::platform::transition::broadcast::BroadcastStateTransition; +use crate::platform::transition::put_document::{ + build_signed_document_replace_transition, ensure_revision_for_replace, +}; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::ensure_valid_state_transition_structure; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; @@ -144,6 +148,8 @@ impl DocumentReplaceTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { + ensure_revision_for_replace(self.document.revision())?; + let owner_id = self.document.owner_id(); let contract_id = self.data_contract.id(); let identity_contract_nonce = sdk @@ -194,6 +200,8 @@ impl DocumentReplaceTransitionBuilder { .document_type_for_name(&self.document_type_name) .map_err(|e| Error::Protocol(e.into()))?; + ensure_revision_for_replace(self.document.revision())?; + let state_transition = BatchTransition::new_document_replacement_transition_from_document( self.document.clone(), document_type, @@ -207,6 +215,8 @@ impl DocumentReplaceTransitionBuilder { ) .await?; + ensure_valid_state_transition_structure(&state_transition, platform_version)?; + Ok(state_transition) } } @@ -254,19 +264,58 @@ impl Sdk { "document_replace: start" ); - let platform_version = self.version(); + // Destructure so we can move builder-owned fields (notably the + // `StateTransitionCreationOptions`, which is not necessarily Clone) + // into the effective settings without an extra copy. + let DocumentReplaceTransitionBuilder { + data_contract, + document_type_name, + document, + token_payment_info, + settings, + user_fee_increase, + state_transition_creation_options, + } = replace_document_transition_builder; + + // Keep original settings for broadcast (request_settings, + // wait_timeout, etc.) and overlay builder-specific + // user_fee_increase / state_transition_creation_options onto the + // effective settings passed to the strict helper. + let broadcast_settings = settings; + let mut effective_settings = settings.unwrap_or_default(); + if let Some(ufi) = user_fee_increase { + effective_settings.user_fee_increase = Some(ufi); + } + if state_transition_creation_options.is_some() { + effective_settings.state_transition_creation_options = + state_transition_creation_options; + } - let put_settings = replace_document_transition_builder.settings; + let document_type = data_contract + .document_type_cloned_for_name(&document_type_name) + .map_err(|e| Error::Protocol(e.into()))?; trace!("document_replace: signing state transition"); - let state_transition = replace_document_transition_builder - .sign(self, signing_key, signer, platform_version) - .await?; + // Route through the strict replace helper so the one-shot + // `document_replace` builder API gets the same fail-fast + // revision-vs-intent validation as the wasm-sdk + // `prepareDocumentReplace` path. Pre-broadcast errors roll back + // the allocated identity-contract nonce inside the helper. + let state_transition = build_signed_document_replace_transition( + self, + &document, + &document_type, + signing_key, + token_payment_info, + signer, + Some(effective_settings), + ) + .await?; trace!("document_replace: state transition signed"); trace!("document_replace: broadcasting and awaiting response"); let proof_result = state_transition - .broadcast_and_wait::(self, put_settings) + .broadcast_and_wait::(self, broadcast_settings) .await?; trace!("document_replace: broadcast completed"); diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index f6aca55cb53..686ce52790d 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -44,10 +44,11 @@ use crate::{Error, Sdk}; 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::DocumentType; +use dpp::data_contract::document_type::{DocumentType, DocumentTypeRef}; 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; @@ -76,7 +77,7 @@ fn ensure_revision_nonzero(revision: Option) -> Result<(), Error> { /// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any /// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side /// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard. -fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { +pub(crate) fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { match revision { None => Ok(()), Some(rev) if rev == INITIAL_REVISION => Ok(()), @@ -92,7 +93,7 @@ fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { /// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`, /// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast /// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard. -fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { +pub(crate) fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { match revision { Some(rev) if rev > INITIAL_REVISION => Ok(()), Some(rev) => Err(Error::InvalidArgument(format!( @@ -107,26 +108,49 @@ fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { } } +/// Centralized v0 document-id derivation. +/// +/// Both the strict create-path id check and the legacy create-path entropy +/// fallback go through this helper so the document-id formula has exactly +/// **one** canonical implementation in this module. +/// +/// # v0 versioning note +/// +/// This helper is intentionally hard-pinned to +/// [`Document::generate_document_id_v0`]. A future protocol version that +/// introduces a new document-id derivation should change this helper (or +/// branch internally on platform version) — grep for `derive_document_id_v0` +/// **and** `generate_document_id_v0` to catch every call site that needs to +/// migrate together. +fn derive_document_id_v0( + document_type: DocumentTypeRef<'_>, + owner_id: &Identifier, + entropy: &[u8; 32], +) -> Identifier { + Document::generate_document_id_v0( + &document_type.data_contract_id(), + owner_id, + document_type.name(), + entropy.as_slice(), + ) +} + /// Strict create-path id check: documents handed to /// [`build_signed_document_create_transition`] must already have their `id` -/// derived from the supplied entropy via [`Document::generate_document_id_v0`]. +/// derived from the supplied entropy via [`derive_document_id_v0`] (currently +/// [`Document::generate_document_id_v0`]). /// /// This guards against silently signing a transition whose committed /// document id does not match the entropy bound into the create transition. /// Callers that want id auto-generation should use the legacy /// [`PutDocument::put_to_platform`] trait method, which still accepts /// `entropy = None` and rewrites the document id before signing. -fn ensure_document_id_matches_entropy( +pub(crate) fn ensure_document_id_matches_entropy( document: &Document, - document_type: &DocumentType, + document_type: DocumentTypeRef<'_>, entropy: &[u8; 32], ) -> Result<(), Error> { - let expected = Document::generate_document_id_v0( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - entropy.as_slice(), - ); + let expected = derive_document_id_v0(document_type, &document.owner_id(), entropy); if document.id() != expected { return Err(Error::InvalidArgument(format!( "document.id does not match \ @@ -153,11 +177,13 @@ fn resolve_document_create_entropy( let mut rng = StdRng::from_entropy(); let mut doc = document.clone(); let entropy = rng.gen::<[u8; 32]>(); - doc.set_id(Document::generate_document_id_v0( - &document_type.data_contract_id(), + // Use the centralized v0 derivation so the legacy auto-generate + // fallback always agrees with the strict id-matches-entropy + // check in `ensure_document_id_matches_entropy`. + doc.set_id(derive_document_id_v0( + document_type.as_ref(), &doc.owner_id(), - document_type.name(), - entropy.as_slice(), + &entropy, )); (doc, entropy) }) @@ -351,27 +377,120 @@ pub async fn build_signed_document_create_transition, signer: &S, settings: Option, +) -> Result { + // Clone-once owned dispatch: the strict create path never re-resolves + // entropy (caller already supplied it), so route past the legacy + // create-or-replace dispatcher to avoid an extra Document clone in the + // `Some(entropy)` branch of `resolve_document_create_entropy`. + build_signed_document_create_transition_owned( + sdk, + document.clone(), + document_type, + document_state_transition_entropy, + identity_public_key, + token_payment_info, + signer, + settings, + ) + .await +} + +/// Internal owned-document variant of +/// [`build_signed_document_create_transition`]. +/// +/// Validates revision and id-matches-entropy **before** any nonce +/// allocation, allocates an identity-contract nonce, and dispatches with +/// the document moved by value so `BatchTransition::new_document_creation_transition_from_document` +/// gets ownership without a second clone. +/// +/// This is the single-clone entry point used by the legacy +/// [`PutDocument::put_to_platform`] None-entropy fallback (which resolves +/// entropy + rewrites the document id once, then hands the owned document +/// to this helper). +#[allow(clippy::too_many_arguments)] +async fn build_signed_document_create_transition_owned>( + sdk: &Sdk, + document: Document, + document_type: &DocumentType, + entropy: [u8; 32], + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, ) -> Result { ensure_revision_for_create(document.revision())?; // Verify the caller's document id matches the entropy *before* we // allocate any identity-contract nonce, so a stale/wrong id never // bumps the local nonce cache. - ensure_document_id_matches_entropy( - document, - document_type, - &document_state_transition_entropy, - )?; - build_signed_document_create_or_replace_transition_legacy( + ensure_document_id_matches_entropy(&document, document_type.as_ref(), &entropy)?; + + let owner_id = document.owner_id(); + let contract_id = document_type.data_contract_id(); + let new_identity_contract_nonce = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, settings) + .await?; + + let result = build_and_sign_create_after_nonce( sdk, document, document_type, - Some(document_state_transition_entropy), + entropy, identity_public_key, token_payment_info, signer, settings, + new_identity_contract_nonce, ) - .await + .await; + + match result { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce( + owner_id, + contract_id, + new_identity_contract_nonce, + ) + .await; + Err(err) + } + } +} + +/// Inner build/sign/validation step for the strict create path. +/// +/// Runs after the identity-contract nonce has been allocated; the caller is +/// responsible for rolling that nonce back if this returns an error. Moves +/// `document` into `BatchTransition::new_document_creation_transition_from_document` +/// so the strict create path performs a single Document clone end-to-end. +#[allow(clippy::too_many_arguments)] +async fn build_and_sign_create_after_nonce>( + sdk: &Sdk, + document: Document, + document_type: &DocumentType, + entropy: [u8; 32], + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, + new_identity_contract_nonce: u64, +) -> Result { + let put_settings = settings.unwrap_or_default(); + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type.as_ref(), + entropy, + identity_public_key, + new_identity_contract_nonce, + put_settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + put_settings.state_transition_creation_options, + ) + .await?; + ensure_valid_state_transition_structure(&transition, sdk.version())?; + Ok(transition) } /// Build, sign, and structurally validate a document **replace** transition @@ -513,29 +632,47 @@ impl> PutDocument for Document { // callers (they aren't picking a branch themselves). ensure_revision_nonzero(self.revision())?; let transition = if self.revision().is_none() || self.revision() == Some(INITIAL_REVISION) { - // Create path. Preserve legacy behavior: when the caller did not - // supply entropy, generate it and rewrite `document.id` so the - // pair stays consistent before we hand the (document, entropy) - // to the strict create helper. The strict helper still verifies - // that `document.id == generate_document_id_v0(entropy)` before - // allocating any nonce, so the legacy fallback cannot mask an - // id/entropy mismatch. - let (resolved_document, resolved_entropy) = resolve_document_create_entropy( - self, - &document_type, - document_state_transition_entropy, - ); - build_signed_document_create_transition( - sdk, - &resolved_document, - &document_type, - resolved_entropy, - &identity_public_key, - token_payment_info, - signer, - settings, - ) - .await? + // Create path. Avoid the outer pre-resolve clone when the + // caller already supplied entropy: pass `self` straight to the + // strict create helper, which clones once internally for + // `BatchTransition::new_document_creation_transition_from_document`. + // + // For the legacy `None` entropy fallback we resolve once here + // (generate entropy + rewrite document id) and hand the owned + // document to `build_signed_document_create_transition_owned`, + // so the create path performs a single Document clone end-to-end. + // The strict id-matches-entropy check runs before any nonce + // allocation in both branches. + match document_state_transition_entropy { + Some(entropy) => { + build_signed_document_create_transition( + sdk, + self, + &document_type, + entropy, + &identity_public_key, + token_payment_info, + signer, + settings, + ) + .await? + } + None => { + let (resolved_document, resolved_entropy) = + resolve_document_create_entropy(self, &document_type, None); + build_signed_document_create_transition_owned( + sdk, + resolved_document, + &document_type, + resolved_entropy, + &identity_public_key, + token_payment_info, + signer, + settings, + ) + .await? + } + } } else { // Replace path: entropy is unused; the strict helper enforces // `revision > INITIAL_REVISION`. @@ -715,6 +852,28 @@ mod tests { assert!(initial.to_string().contains("replace requires revision")); } + #[test] + fn derive_document_id_v0_matches_generate_document_id_v0() { + // The centralized helper must produce the same id bytes as the + // underlying `Document::generate_document_id_v0` for any + // (document_type, owner_id, entropy) triple — otherwise the strict + // id-matches-entropy guard and the legacy auto-generate fallback + // could disagree silently. + let document_type = test_document_type(); + let owner_id = Identifier::from([0x42; 32]); + let entropy = [0xCCu8; 32]; + + let derived = derive_document_id_v0(document_type.as_ref(), &owner_id, &entropy); + let direct = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &owner_id, + document_type.name(), + entropy.as_slice(), + ); + + assert_eq!(derived, direct); + } + #[test] fn creation_entropy_fallback_regenerates_document_id() { let document_type = test_document_type(); diff --git a/packages/wasm-sdk/README.md b/packages/wasm-sdk/README.md index 8e011802396..6c872c67a73 100644 --- a/packages/wasm-sdk/README.md +++ b/packages/wasm-sdk/README.md @@ -178,6 +178,7 @@ The full surface includes identity, document, contract, token, group, epoch/syst ### Document revision rules - `documentCreate()` requires the document revision to be omitted or set to `1` (`INITIAL_REVISION`). +- `documentCreate()` and `prepareDocumentCreate()` require `document.id` to match the id derived from `(dataContractId, ownerId, documentTypeName, entropy)`; mismatches reject with `InvalidArgument` before nonce allocation. - `documentReplace()` requires the document revision to be greater than `1`. - Invalid one-shot document revisions now reject with `InvalidArgument`; the wasm SDK no longer silently routes them to the other transition type. diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 61a7113a349..9c6621e3f5c 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -53,6 +53,22 @@ //! Earlier wasm-sdk behavior could silently route invalid revisions to the other //! transition type. That implicit routing is no longer performed: invalid //! revisions now fail with `InvalidArgument` instead. +//! +//! ## Document id ↔ entropy invariant (create paths) +//! +//! `documentCreate()` / `prepareDocumentCreate()` now require that the +//! document's `id` matches the id derived from +//! `(dataContractId, ownerId, documentTypeName, entropy)` via the v0 document +//! id derivation. Mismatches are rejected with `InvalidArgument` **before** +//! any identity-contract nonce is allocated, so failed calls do not advance +//! the local nonce cache. +//! +//! **Migration / compatibility note:** earlier wasm-sdk behavior accepted any +//! `id` and silently embedded the document under whatever id the caller had +//! set. If you previously built a `Document` with a hand-picked id and a +//! separate entropy value, you must now either let the `Document` +//! constructor derive both together (its default behavior) or call +//! `Document.generateId(...)` with the same entropy you intend to use. use crate::error::WasmSdkError; use crate::sdk::WasmSdk; @@ -138,10 +154,23 @@ fn try_from_options_optional_token_payment_info( return Ok(None); } - let token_payment_info = TokenPaymentInfoWasm::constructor( - token_payment_info_value.unchecked_into::(), - ) - .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?; + // Accept either an existing wasm-dpp2 `TokenPaymentInfo` class instance + // (e.g. produced by `new TokenPaymentInfo(...)` or returned by another + // wasm-sdk accessor) **or** a plain `DocumentTokenPaymentInfo` options + // bag. We detect the class type via the `__type` getter set up by + // `impl_wasm_type_info!` and convert via `TryFrom<&JsValue>` (which uses + // the `IntoWasm` pointer extraction) before falling back to the + // constructor-from-options path for plain objects. + let class_type = get_class_type(&token_payment_info_value).unwrap_or_default(); + let token_payment_info = if class_type == "TokenPaymentInfo" { + TokenPaymentInfoWasm::try_from(&token_payment_info_value) + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? + } else { + TokenPaymentInfoWasm::constructor( + token_payment_info_value.unchecked_into::(), + ) + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? + }; Ok(Some(token_payment_info.into())) } @@ -164,6 +193,13 @@ export interface DocumentCreateOptions { * Revision must be omitted or set to 1 (INITIAL_REVISION). * Other revisions are rejected with InvalidArgument instead of being routed * to documentReplace(). + * + * **Migration note (id ↔ entropy invariant):** `document.id` must match + * the id derived from `(dataContractId, ownerId, documentTypeName, entropy)` + * via the v0 document-id derivation. Mismatches are rejected with + * `InvalidArgument` before any identity-contract nonce is allocated. The + * `Document` constructor derives both together by default; if you set the + * id or entropy explicitly, keep them consistent. */ document: Document; @@ -568,7 +604,17 @@ const PREPARE_DOCUMENT_CREATE_OPTIONS_TS: &'static str = r#" * 4. On timeout, deserialize cached bytes and rebroadcast the **identical** ST */ export interface PrepareDocumentCreateOptions { - /** The document to create. */ + /** + * The document to create. + * + * **Migration note (id ↔ entropy invariant):** `document.id` must match + * the id derived from `(dataContractId, ownerId, documentTypeName, entropy)` + * via the v0 document-id derivation. Mismatches are rejected with + * `InvalidArgument` before any identity-contract nonce is allocated, so + * failed calls do not advance the local nonce cache. The `Document` + * constructor derives both together by default; if you set the id or + * entropy explicitly, keep them consistent. + */ document: Document; /** The identity public key to use for signing. */ identityKey: IdentityPublicKey; diff --git a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts index cddbf167346..2de04065c75 100644 --- a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -115,6 +115,26 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); + it('rejects a document whose id does not match its entropy', async () => { + // The strict create path requires document.id to be derived from + // (dataContractId, ownerId, documentTypeName, entropy) via the v0 + // document-id derivation. Manually overwriting `id` after construction + // breaks that invariant and must fail with InvalidArgument before + // any nonce allocation. + const { signer, identityKey } = buildSigner(); + const document = buildDocument(); + document.id = DUMMY_ID; + + try { + await client.prepareDocumentCreate({ document, identityKey, signer }); + expect.fail('expected prepareDocumentCreate to reject mismatched id/entropy'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/does not match/i); + } + }); + it('rejects a document with revision 0 (would silently be a replace)', async () => { // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` // to the replace branch, so `revision = 0` must be rejected too — otherwise it would From 730f6b30c8c05ee766614aef4ae9b823e5d93a9b Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 13 May 2026 05:05:02 -0500 Subject: [PATCH 20/24] fix(sdk): address document prepare review follow-up --- packages/rs-sdk/README.md | 3 + .../platform/documents/transitions/create.rs | 187 +++++++++++- .../platform/documents/transitions/delete.rs | 75 ++--- .../platform/documents/transitions/replace.rs | 148 +++++++++- .../src/platform/transition/put_document.rs | 64 +++- .../src/state_transitions/document.rs | 277 +++++++++++++++--- 6 files changed, 672 insertions(+), 82 deletions(-) diff --git a/packages/rs-sdk/README.md b/packages/rs-sdk/README.md index bee848f418d..718d939df32 100644 --- a/packages/rs-sdk/README.md +++ b/packages/rs-sdk/README.md @@ -49,6 +49,9 @@ You can find quick start example in `examples/` folder. Examples must be configu ## Compatibility notes - `DocumentDeleteTransitionBuilder::with_settings(settings).sign(...)` now honors `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Native rs-sdk callers that passed delete transition fee/creation options through `PutSettings` may now see those intended values take effect on the wire instead of being silently dropped. +- `DocumentCreateTransitionBuilder` and `DocumentReplaceTransitionBuilder` apply the same rule as the delete builder: `with_settings(settings).sign_with_nonce(...)` (and `.sign(...)`) now honor `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Explicit `with_user_fee_increase` / `with_state_transition_creation_options` calls still win regardless of order. Native callers that previously passed these via `PutSettings` should expect them to take effect on the wire. +- `DocumentDeleteTransitionBuilder::sign` now rolls back the allocated identity-contract nonce on pre-broadcast failure (matching `DocumentCreateTransitionBuilder::sign` / `DocumentReplaceTransitionBuilder::sign`). `DocumentDeleteTransitionBuilder::sign_with_nonce` now also runs `ensure_valid_state_transition_structure` before returning, so direct builder users get the same pre-broadcast guarantees as the shared `build_signed_document_delete_transition` helper. +- `ensure_valid_state_transition_structure` (used by the wasm-sdk `prepareDocument*` flows and the rs-sdk document create/replace/delete builders) filters `UnsupportedFeatureError` entries out of mixed structure-validation results before surfacing the remaining errors. This intentionally lets identity-based state transitions, which DPP's structure validator still reports as `UnsupportedFeatureError`, through to broadcast where execution-time validation runs. Callers that previously relied on `UnsupportedFeatureError` surfacing through this path will no longer see those entries — only the real validation failures. diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index c891fa86171..5f499632836 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -81,7 +81,12 @@ impl DocumentCreateTransitionBuilder { self } - /// Adds a user fee increase to the document create transition + /// Adds a user fee increase to the document create transition. + /// + /// The dedicated [`Self::user_fee_increase`] field is the single source + /// of truth for the effective value applied at sign time. Explicit + /// setters always win regardless of call order — see + /// [`Self::with_settings`] for the order-independence contract. /// /// # Arguments /// @@ -95,7 +100,21 @@ impl DocumentCreateTransitionBuilder { self } - /// Adds settings to the document create transition + /// Adds settings to the document create transition. + /// + /// `user_fee_increase` and `state_transition_creation_options` are owned + /// by their dedicated builder fields, which are the single source of + /// truth for the effective values applied at sign time. This method + /// extracts those two fields out of the supplied [`PutSettings`] into + /// the dedicated fields **only if** the dedicated field is still + /// `None`, and then stores the remainder of `settings` (with those two + /// fields cleared) on the builder. + /// + /// Net effect: explicit setters always win over `with_settings` for + /// `user_fee_increase` and `state_transition_creation_options`, regardless + /// of call order. All other [`PutSettings`] fields (timeouts, retry + /// behavior, nonce stale time, etc.) flow through unchanged to be used + /// for nonce allocation and broadcast. /// /// # Arguments /// @@ -104,12 +123,28 @@ impl DocumentCreateTransitionBuilder { /// # Returns /// /// * `Self` - The updated builder - pub fn with_settings(mut self, settings: PutSettings) -> Self { + pub fn with_settings(mut self, mut settings: PutSettings) -> Self { + if self.user_fee_increase.is_none() { + self.user_fee_increase = settings.user_fee_increase; + } + if self.state_transition_creation_options.is_none() { + self.state_transition_creation_options = settings.state_transition_creation_options; + } + // Strip the fee/creation-options fields from the stored settings so + // the dedicated builder fields are the sole source of truth at + // sign time. The remainder of `settings` flows through unchanged. + settings.user_fee_increase = None; + settings.state_transition_creation_options = None; self.settings = Some(settings); self } - /// Adds creation_options to the document create transition + /// Adds creation_options to the document create transition. + /// + /// The dedicated [`Self::state_transition_creation_options`] field is the + /// single source of truth for the effective value applied at sign time. + /// Explicit setters always win regardless of call order — see + /// [`Self::with_settings`] for the order-independence contract. /// /// # Arguments /// @@ -244,6 +279,150 @@ impl DocumentCreateTransitionBuilder { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::transition::put_settings::PutSettings; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::DocumentV0; + use dpp::platform_value::Identifier as PVIdentifier; + use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::PlatformVersion; + + fn fixture_data_contract() -> Arc { + Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ) + } + + fn fixture_document(contract: &DataContract) -> Document { + Document::V0(DocumentV0 { + id: PVIdentifier::from([1u8; 32]), + owner_id: contract.owner_id(), + properties: Default::default(), + 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, + }) + } + + /// `with_settings` must extract `user_fee_increase` and + /// `state_transition_creation_options` from the supplied `PutSettings` + /// into the dedicated builder fields so `sign_with_nonce` (which reads + /// only the dedicated fields) honors them on the wire. + #[test] + fn with_settings_extracts_fee_and_options_into_dedicated_fields() { + let data_contract = fixture_data_contract(); + let document = fixture_document(&data_contract); + let settings = PutSettings { + user_fee_increase: Some(42), + state_transition_creation_options: Some(StateTransitionCreationOptions::default()), + ..Default::default() + }; + + let builder = DocumentCreateTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + [0u8; 32], + ) + .with_settings(settings); + + assert_eq!(builder.user_fee_increase, settings.user_fee_increase); + assert_eq!( + builder.state_transition_creation_options, + settings.state_transition_creation_options + ); + // Stored settings has those two fields cleared so the dedicated + // fields are the sole source of truth at sign time. + let stored = builder.settings.expect("settings must be stored"); + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); + } + + /// Explicit setters must beat `with_settings` regardless of call order + /// (mirrors the delete builder's contract). + #[test] + fn explicit_setters_beat_settings_regardless_of_order() { + let data_contract = fixture_data_contract(); + let document = fixture_document(&data_contract); + + let settings_first = DocumentCreateTransitionBuilder::new( + data_contract.clone(), + "niceDocument".to_string(), + document.clone(), + [0u8; 32], + ) + .with_settings(PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }) + .with_user_fee_increase(42); + + let explicit_first = DocumentCreateTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + [0u8; 32], + ) + .with_user_fee_increase(42) + .with_settings(PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }); + + assert_eq!(settings_first.user_fee_increase, Some(42)); + assert_eq!(explicit_first.user_fee_increase, Some(42)); + } + + /// `with_settings` must preserve every non-fee/options `PutSettings` + /// field as supplied so timeouts, retry behavior, and nonce stale time + /// still drive nonce allocation and broadcast. + #[test] + fn with_settings_preserves_other_put_settings_fields() { + let data_contract = fixture_data_contract(); + let document = fixture_document(&data_contract); + let original = PutSettings { + user_fee_increase: Some(7), + state_transition_creation_options: Some(StateTransitionCreationOptions::default()), + identity_nonce_stale_time_s: Some(123), + wait_timeout: Some(std::time::Duration::from_secs(45)), + ..Default::default() + }; + + let builder = DocumentCreateTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + [0u8; 32], + ) + .with_settings(original); + + let stored = builder.settings.expect("settings must be stored"); + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); + assert_eq!( + stored.identity_nonce_stale_time_s, + original.identity_nonce_stale_time_s + ); + assert_eq!(stored.wait_timeout, original.wait_timeout); + } +} + /// Result types returned from document creation operations. #[derive(Debug)] pub enum DocumentCreateResult { diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 7d82d774945..097445fc63f 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -184,10 +184,13 @@ impl DocumentDeleteTransitionBuilder { /// Signs the document delete transition. /// /// Allocates a fresh identity-contract nonce from `sdk` and delegates to - /// [`Self::sign_with_nonce`]. Use [`Self::sign_with_nonce`] directly if - /// you need to pre-allocate the nonce so a pre-broadcast failure can roll - /// it back via - /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce). + /// [`Self::sign_with_nonce`]. If signing fails *after* the nonce has been + /// allocated, the bumped identity-contract nonce is conditionally rolled + /// back via + /// [`Sdk::rollback_identity_contract_nonce`](crate::Sdk::rollback_identity_contract_nonce) + /// so the local cache does not advance past a nonce the network never + /// observed. Use [`Self::sign_with_nonce`] directly if you need to + /// pre-allocate the nonce yourself and handle rollback at the call site. /// /// # Arguments /// @@ -206,22 +209,32 @@ impl DocumentDeleteTransitionBuilder { signer: &impl Signer, platform_version: &PlatformVersion, ) -> Result { + let owner_id = self.owner_id; + let contract_id = self.data_contract.id(); let identity_contract_nonce = sdk - .get_identity_contract_nonce( - self.owner_id, - self.data_contract.id(), - true, - self.settings, - ) + .get_identity_contract_nonce(owner_id, contract_id, true, self.settings) .await?; - self.sign_with_nonce( - identity_contract_nonce, - identity_public_key, - signer, - platform_version, - ) - .await + match self + .sign_with_nonce( + identity_contract_nonce, + identity_public_key, + signer, + platform_version, + ) + .await + { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce( + owner_id, + contract_id, + identity_contract_nonce, + ) + .await; + Err(err) + } + } } /// Signs the document delete transition using a pre-allocated @@ -277,6 +290,11 @@ impl DocumentDeleteTransitionBuilder { ) .await?; + // Run the same structure validation as create/replace so callers + // of the builder API get the same pre-broadcast guarantees as the + // shared `build_signed_document_delete_transition` helper. + ensure_valid_state_transition_structure(&state_transition, platform_version)?; + Ok(state_transition) } } @@ -300,10 +318,9 @@ pub enum DocumentDeleteResult { /// /// - allocates a fresh identity-contract nonce via /// [`Sdk::get_identity_contract_nonce`] with `bump_first = true`, -/// - signs the transition by delegating to -/// [`DocumentDeleteTransitionBuilder::sign_with_nonce`], -/// - runs structure validation via -/// [`ensure_valid_state_transition_structure`], and +/// - signs and structurally validates the transition by delegating to +/// [`DocumentDeleteTransitionBuilder::sign_with_nonce`] (which calls +/// [`ensure_valid_state_transition_structure`] internally), and /// - on any **pre-broadcast** error (sign/build or local structure /// validation) conditionally rolls the bumped identity-contract nonce /// back via [`Sdk::rollback_identity_contract_nonce`], so the local @@ -324,7 +341,7 @@ pub async fn build_signed_document_delete_transition transition, + Ok(transition) => Ok(transition), Err(err) => { sdk.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) .await; - return Err(err); + Err(err) } - }; - - if let Err(err) = ensure_valid_state_transition_structure(&state_transition, sdk.version()) { - sdk.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) - .await; - return Err(err); } - - Ok(state_transition) } #[cfg(test)] @@ -675,7 +684,7 @@ mod tests { Identifier::default(), Identifier::default(), ) - .with_settings(original_settings.clone()) + .with_settings(original_settings) .with_user_fee_increase(42) .with_state_transition_creation_options(explicit_options); diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index 816c4b8d952..bb77daa77f0 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -75,7 +75,12 @@ impl DocumentReplaceTransitionBuilder { self } - /// Adds a user fee increase to the document replace transition + /// Adds a user fee increase to the document replace transition. + /// + /// The dedicated [`Self::user_fee_increase`] field is the single source + /// of truth for the effective value applied at sign time. Explicit + /// setters always win regardless of call order — see + /// [`Self::with_settings`] for the order-independence contract. /// /// # Arguments /// @@ -89,7 +94,21 @@ impl DocumentReplaceTransitionBuilder { self } - /// Adds settings to the document replace transition + /// Adds settings to the document replace transition. + /// + /// `user_fee_increase` and `state_transition_creation_options` are owned + /// by their dedicated builder fields, which are the single source of + /// truth for the effective values applied at sign time. This method + /// extracts those two fields out of the supplied [`PutSettings`] into + /// the dedicated fields **only if** the dedicated field is still + /// `None`, and then stores the remainder of `settings` (with those two + /// fields cleared) on the builder. + /// + /// Net effect: explicit setters always win over `with_settings` for + /// `user_fee_increase` and `state_transition_creation_options`, regardless + /// of call order. All other [`PutSettings`] fields (timeouts, retry + /// behavior, nonce stale time, etc.) flow through unchanged to be used + /// for nonce allocation and broadcast. /// /// # Arguments /// @@ -98,12 +117,28 @@ impl DocumentReplaceTransitionBuilder { /// # Returns /// /// * `Self` - The updated builder - pub fn with_settings(mut self, settings: PutSettings) -> Self { + pub fn with_settings(mut self, mut settings: PutSettings) -> Self { + if self.user_fee_increase.is_none() { + self.user_fee_increase = settings.user_fee_increase; + } + if self.state_transition_creation_options.is_none() { + self.state_transition_creation_options = settings.state_transition_creation_options; + } + // Strip the fee/creation-options fields from the stored settings so + // the dedicated builder fields are the sole source of truth at + // sign time. The remainder of `settings` flows through unchanged. + settings.user_fee_increase = None; + settings.state_transition_creation_options = None; self.settings = Some(settings); self } - /// Adds creation_options to the document replace transition + /// Adds creation_options to the document replace transition. + /// + /// The dedicated [`Self::state_transition_creation_options`] field is the + /// single source of truth for the effective value applied at sign time. + /// Explicit setters always win regardless of call order — see + /// [`Self::with_settings`] for the order-independence contract. /// /// # Arguments /// @@ -221,6 +256,111 @@ impl DocumentReplaceTransitionBuilder { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::transition::put_settings::PutSettings; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::{DocumentV0, INITIAL_REVISION}; + use dpp::platform_value::Identifier as PVIdentifier; + use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; + use dpp::tests::fixtures::get_data_contract_fixture; + use dpp::version::PlatformVersion; + + fn fixture_data_contract() -> Arc { + Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ) + } + + fn fixture_replace_document(contract: &DataContract) -> Document { + Document::V0(DocumentV0 { + id: PVIdentifier::from([1u8; 32]), + owner_id: contract.owner_id(), + properties: Default::default(), + revision: Some(INITIAL_REVISION + 1), + 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, + }) + } + + /// `with_settings` must extract `user_fee_increase` and + /// `state_transition_creation_options` from the supplied `PutSettings` + /// into the dedicated builder fields so `sign_with_nonce` (which reads + /// only the dedicated fields) honors them on the wire. + #[test] + fn with_settings_extracts_fee_and_options_into_dedicated_fields() { + let data_contract = fixture_data_contract(); + let document = fixture_replace_document(&data_contract); + let settings = PutSettings { + user_fee_increase: Some(42), + state_transition_creation_options: Some(StateTransitionCreationOptions::default()), + ..Default::default() + }; + + let builder = DocumentReplaceTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + ) + .with_settings(settings); + + assert_eq!(builder.user_fee_increase, settings.user_fee_increase); + assert_eq!( + builder.state_transition_creation_options, + settings.state_transition_creation_options + ); + let stored = builder.settings.expect("settings must be stored"); + assert_eq!(stored.user_fee_increase, None); + assert_eq!(stored.state_transition_creation_options, None); + } + + /// Explicit setters must beat `with_settings` regardless of call order. + #[test] + fn explicit_setters_beat_settings_regardless_of_order() { + let data_contract = fixture_data_contract(); + let document = fixture_replace_document(&data_contract); + + let settings_first = DocumentReplaceTransitionBuilder::new( + data_contract.clone(), + "niceDocument".to_string(), + document.clone(), + ) + .with_settings(PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }) + .with_user_fee_increase(42); + + let explicit_first = DocumentReplaceTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + ) + .with_user_fee_increase(42) + .with_settings(PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }); + + assert_eq!(settings_first.user_fee_increase, Some(42)); + assert_eq!(explicit_first.user_fee_increase, Some(42)); + } +} + /// Result types returned from document replace operations. #[derive(Debug)] pub enum DocumentReplaceResult { diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 686ce52790d..c9fb61b4618 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -517,18 +517,76 @@ pub async fn build_signed_document_replace_transition, ) -> Result { + // Validate revision *before* allocating a nonce so caller mistakes + // never bump the local nonce cache. ensure_revision_for_replace(document.revision())?; - build_signed_document_create_or_replace_transition_legacy( + + let owner_id = document.owner_id(); + let contract_id = document_type.data_contract_id(); + let new_identity_contract_nonce = sdk + .get_identity_contract_nonce(owner_id, contract_id, true, settings) + .await?; + + let result = build_and_sign_replace_after_nonce( sdk, document, document_type, - None, // entropy is unused on the replace path identity_public_key, token_payment_info, signer, settings, + new_identity_contract_nonce, ) - .await + .await; + + match result { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce( + owner_id, + contract_id, + new_identity_contract_nonce, + ) + .await; + Err(err) + } + } +} + +/// Inner build/sign/validation step for the strict replace path. +/// +/// Runs after the identity-contract nonce has been allocated; the caller is +/// responsible for rolling that nonce back if this returns an error. The +/// replace path goes straight to +/// [`BatchTransition::new_document_replacement_transition_from_document`] +/// — entropy is intentionally not threaded through here because replacement +/// transitions do not derive a new document id. +#[allow(clippy::too_many_arguments)] +async fn build_and_sign_replace_after_nonce>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, + new_identity_contract_nonce: u64, +) -> Result { + let put_settings = settings.unwrap_or_default(); + let transition = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type.as_ref(), + identity_public_key, + new_identity_contract_nonce, + put_settings.user_fee_increase.unwrap_or_default(), + token_payment_info, + signer, + sdk.version(), + put_settings.state_transition_creation_options, + ) + .await?; + ensure_valid_state_transition_structure(&transition, sdk.version())?; + Ok(transition) } /// Inner build/sign/validation step shared by the create-or-replace dispatch. diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 9c6621e3f5c..367652503f7 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -100,7 +100,7 @@ use wasm_dpp2::state_transitions::batch::token_payment_info::{ }; use wasm_dpp2::utils::{ get_class_type, try_from_options_optional, try_from_options_with, try_to_string, try_to_u64, - IntoWasm, + IntoWasm, JsValueExt, }; use wasm_dpp2::IdentitySignerWasm; use wasm_dpp2::StateTransitionWasm; @@ -154,27 +154,76 @@ fn try_from_options_optional_token_payment_info( return Ok(None); } - // Accept either an existing wasm-dpp2 `TokenPaymentInfo` class instance - // (e.g. produced by `new TokenPaymentInfo(...)` or returned by another - // wasm-sdk accessor) **or** a plain `DocumentTokenPaymentInfo` options - // bag. We detect the class type via the `__type` getter set up by - // `impl_wasm_type_info!` and convert via `TryFrom<&JsValue>` (which uses - // the `IntoWasm` pointer extraction) before falling back to the - // constructor-from-options path for plain objects. - let class_type = get_class_type(&token_payment_info_value).unwrap_or_default(); - let token_payment_info = if class_type == "TokenPaymentInfo" { - TokenPaymentInfoWasm::try_from(&token_payment_info_value) - .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? - } else { - TokenPaymentInfoWasm::constructor( + // We support two input shapes for `tokenPaymentInfo`: + // + // 1. A plain `DocumentTokenPaymentInfo` options bag (no `__type` + // marker) — parsed via the public constructor. + // 2. An existing wasm-dpp2 `TokenPaymentInfo` class instance produced + // by `new TokenPaymentInfo(...)` (whose `__type` getter returns + // `"TokenPaymentInfo"`) — copied through its public getters and then + // parsed via the public constructor. + // + // Avoid `TokenPaymentInfoWasm::try_from(&value)` here: that path reads + // the wasm-bindgen `__wbg_ptr` field, and this API accepts untrusted JS + // values. A forged object can spoof the public `__type` getter/string, + // but it cannot force us to dereference an arbitrary wasm pointer when + // we only copy public fields into a fresh options bag. + let class_type = get_class_type(&token_payment_info_value) + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?; + let token_payment_info = match class_type.as_str() { + "TokenPaymentInfo" => TokenPaymentInfoWasm::constructor( + token_payment_info_options_from_public_fields(&token_payment_info_value)?, + ) + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?, + // Plain object path: no `__type` getter is set up, so + // `get_class_type` returns `Ok("")` (empty string default in + // `JsValue::as_string().unwrap_or_default()`). Treat the empty + // string the same as "no class marker present". + "" => TokenPaymentInfoWasm::constructor( token_payment_info_value.unchecked_into::(), ) - .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))? + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?, + other => { + return Err(WasmSdkError::invalid_argument(format!( + "tokenPaymentInfo must be a plain DocumentTokenPaymentInfo options object \ + or a TokenPaymentInfo instance, got class '{other}'" + ))); + } }; Ok(Some(token_payment_info.into())) } +fn token_payment_info_options_from_public_fields( + value: &JsValue, +) -> Result { + let options = js_sys::Object::new(); + for field in [ + "paymentTokenContractId", + "tokenContractPosition", + "minimumTokenCost", + "maximumTokenCost", + "gasFeesPaidBy", + ] { + let field_value = Reflect::get(value, &JsValue::from_str(field)).map_err(|err| { + WasmSdkError::invalid_argument(format!( + "failed to read tokenPaymentInfo.{field}: {}", + err.error_message() + )) + })?; + if !field_value.is_undefined() { + Reflect::set(&options, &JsValue::from_str(field), &field_value).map_err(|err| { + WasmSdkError::invalid_argument(format!( + "failed to copy tokenPaymentInfo.{field}: {}", + err.error_message() + )) + })?; + } + } + + Ok(options.unchecked_into::()) +} + // ============================================================================ // Document Create // ============================================================================ @@ -279,6 +328,18 @@ impl WasmSdk { let mut entropy_array = [0u8; 32]; entropy_array.copy_from_slice(&entropy); + // Reject id-vs-entropy mismatches *before* fetching the contract. + // The same invariant is independently enforced by the strict rs-sdk + // helper as the security boundary; this just saves a round trip on + // caller mistakes. + ensure_document_id_matches_entropy_fast( + document.id(), + contract_id, + document.owner_id(), + &document_type_name, + &entropy_array, + )?; + // Extract identity key from options let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; let identity_key: IdentityPublicKey = identity_key_wasm.into(); @@ -685,6 +746,18 @@ impl WasmSdk { let mut entropy_array = [0u8; 32]; entropy_array.copy_from_slice(&entropy); + // Reject id-vs-entropy mismatches *before* fetching the contract. + // The same invariant is independently enforced by the strict rs-sdk + // helper as the security boundary; this just saves a round trip on + // caller mistakes. + ensure_document_id_matches_entropy_fast( + document.id(), + contract_id, + document.owner_id(), + &document_type_name, + &entropy_array, + )?; + // Extract identity key from options let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; let identity_key: IdentityPublicKey = identity_key_wasm.into(); @@ -1338,41 +1411,87 @@ impl WasmSdk { // Helper Functions // ============================================================================ +/// Fast-fail verification that `document.id` matches the v0 document-id +/// derivation for `(contract_id, owner_id, document_type_name, entropy)`. +/// +/// This is the same invariant the strict create helper enforces, but lifted +/// out of the rs-sdk path so wasm-sdk `documentCreate` / +/// `prepareDocumentCreate` can reject mismatches **before** the contract is +/// fetched from Platform (or read from local cache), saving a round trip on +/// caller mistakes. The rs-sdk helper still enforces this independently as +/// the security boundary; this is purely an early reject. +fn ensure_document_id_matches_entropy_fast( + document_id: Identifier, + contract_id: Identifier, + owner_id: Identifier, + document_type_name: &str, + entropy: &[u8; 32], +) -> Result<(), WasmSdkError> { + let expected = Document::generate_document_id_v0( + &contract_id, + &owner_id, + document_type_name, + entropy.as_slice(), + ); + if document_id != expected { + return Err(WasmSdkError::invalid_argument(format!( + "document.id does not match \ + generate_document_id_v0(dataContractId, ownerId, documentTypeName, entropy); \ + expected {expected}, got {document_id}. \ + The Document constructor derives both together by default; if you set the \ + id or entropy explicitly, keep them consistent." + ))); + } + Ok(()) +} + fn ensure_document_create_revision( revision: Option, replace_api_name: &str, ) -> Result<(), WasmSdkError> { - if let Some(revision) = revision { - if revision != INITIAL_REVISION { - return Err(WasmSdkError::invalid_argument(format!( - "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", - revision, INITIAL_REVISION, replace_api_name, - ))); - } + match revision { + None => Ok(()), + Some(rev) if rev == INITIAL_REVISION => Ok(()), + // `Some(0)` is invalid for *both* create and replace, so do not + // point users at the sibling API — they would just see the same + // rejection from `ensure_document_replace_revision`. Emit a + // dedicated message that makes the always-invalid value explicit. + Some(0) => Err(WasmSdkError::invalid_argument(format!( + "Document revision is 0 but revision 0 is invalid for both create and replace. \ + Use unset or {} (INITIAL_REVISION) for create, or > {} for replace.", + INITIAL_REVISION, INITIAL_REVISION, + ))), + Some(rev) => Err(WasmSdkError::invalid_argument(format!( + "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", + rev, INITIAL_REVISION, replace_api_name, + ))), } - - Ok(()) } fn ensure_document_replace_revision( revision: Option, create_api_name: &str, ) -> Result<(), WasmSdkError> { - let revision = revision.ok_or_else(|| { - WasmSdkError::invalid_argument(format!( + match revision { + Some(rev) if rev > INITIAL_REVISION => Ok(()), + // `Some(0)` is invalid for *both* create and replace, so do not + // point users at the sibling API — they would just see the same + // rejection from `ensure_document_create_revision`. Emit a + // dedicated message that makes the always-invalid value explicit. + Some(0) => Err(WasmSdkError::invalid_argument(format!( + "Document revision is 0 but revision 0 is invalid for both create and replace. \ + Use unset or {} (INITIAL_REVISION) for create, or > {} for replace.", + INITIAL_REVISION, INITIAL_REVISION, + ))), + Some(rev) => Err(WasmSdkError::invalid_argument(format!( + "Document revision is {} but replace requires revision > {}. Use {} for new documents.", + rev, INITIAL_REVISION, create_api_name, + ))), + None => Err(WasmSdkError::invalid_argument(format!( "Document must have a revision set for replace. Use {} for new documents.", create_api_name, - )) - })?; - - if revision <= INITIAL_REVISION { - return Err(WasmSdkError::invalid_argument(format!( - "Document revision is {} but replace requires revision > {}. Use {} for new documents.", - revision, INITIAL_REVISION, create_api_name, - ))); + ))), } - - Ok(()) } /// Get an owned DocumentType from a DataContract @@ -1409,12 +1528,50 @@ mod tests { #[test] fn create_revision_guard_rejects_non_initial_revision() { - let err = ensure_document_create_revision(Some(0), "prepareDocumentReplace") - .expect_err("revision 0 should fail"); + let err = ensure_document_create_revision(Some(2), "prepareDocumentReplace") + .expect_err("revision > INITIAL_REVISION should fail"); assert!(err.to_string().contains("prepareDocumentReplace")); assert!(err.to_string().contains("create requires revision")); } + /// Revision `Some(0)` is invalid for *both* create and replace. The + /// rejection message must therefore not point users at the sibling + /// API (which would also reject), it must say revision 0 is invalid + /// for both paths. + #[test] + fn create_revision_guard_rejects_zero_with_dedicated_message() { + let err = ensure_document_create_revision(Some(0), "prepareDocumentReplace") + .expect_err("revision 0 should fail"); + let msg = err.to_string(); + assert!( + msg.contains("revision 0 is invalid for both create and replace"), + "expected dedicated revision-0 message, got: {msg}" + ); + assert!( + !msg.contains("prepareDocumentReplace"), + "revision-0 message must not point users at the sibling API which also rejects: {msg}" + ); + } + + /// Revision `Some(0)` is invalid for *both* create and replace. The + /// rejection message must therefore not point users at the sibling + /// API (which would also reject), it must say revision 0 is invalid + /// for both paths. + #[test] + fn replace_revision_guard_rejects_zero_with_dedicated_message() { + let err = ensure_document_replace_revision(Some(0), "prepareDocumentCreate") + .expect_err("revision 0 should fail"); + let msg = err.to_string(); + assert!( + msg.contains("revision 0 is invalid for both create and replace"), + "expected dedicated revision-0 message, got: {msg}" + ); + assert!( + !msg.contains("prepareDocumentCreate"), + "revision-0 message must not point users at the sibling API which also rejects: {msg}" + ); + } + #[test] fn replace_revision_guard_accepts_only_greater_than_initial_revision() { assert!(ensure_document_replace_revision( @@ -1437,6 +1594,50 @@ mod tests { assert!(initial.to_string().contains("replace requires revision")); } + /// `ensure_document_id_matches_entropy_fast` must produce the same + /// derivation as `Document::generate_document_id_v0`, so a matching id + /// passes and a non-matching id is rejected with a clear message. This + /// lets `documentCreate` / `prepareDocumentCreate` reject caller + /// mistakes before fetching the contract. + #[test] + fn fast_id_matches_entropy_accepts_matching_id_and_rejects_mismatch() { + let contract_id = Identifier::from([1u8; 32]); + let owner_id = Identifier::from([2u8; 32]); + let entropy = [3u8; 32]; + let document_type_name = "note"; + let expected = Document::generate_document_id_v0( + &contract_id, + &owner_id, + document_type_name, + entropy.as_slice(), + ); + + assert!(ensure_document_id_matches_entropy_fast( + expected, + contract_id, + owner_id, + document_type_name, + &entropy, + ) + .is_ok()); + + let bogus = Identifier::from([0xAB; 32]); + assert_ne!(bogus, expected, "test precondition"); + let err = ensure_document_id_matches_entropy_fast( + bogus, + contract_id, + owner_id, + document_type_name, + &entropy, + ) + .expect_err("mismatch must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("does not match"), + "expected id-mismatch message, got: {msg}" + ); + } + /// Regression test for the UnsupportedFeatureError pass-through path. /// /// DPP's `validate_structure` implementation returns `UnsupportedFeatureError` From 18a7df11d04aa12315a9b32b056471f0d01bc9fa Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 13 May 2026 06:43:54 -0500 Subject: [PATCH 21/24] fix(sdk): address document prepare review findings --- packages/rs-sdk/README.md | 3 +- .../platform/documents/transitions/create.rs | 19 +- .../platform/documents/transitions/delete.rs | 19 +- .../platform/documents/transitions/replace.rs | 19 +- .../src/platform/transition/put_document.rs | 11 + .../src/platform/transition/put_settings.rs | 37 ++ .../src/platform/transition/validation.rs | 138 ++++- .../src/data_contract/document/model.rs | 9 + .../src/state_transitions/document.rs | 568 ++++++++++++++---- .../tests/unit/prepare-document.spec.ts | 86 ++- 10 files changed, 725 insertions(+), 184 deletions(-) diff --git a/packages/rs-sdk/README.md b/packages/rs-sdk/README.md index 718d939df32..bcd8ce42c1d 100644 --- a/packages/rs-sdk/README.md +++ b/packages/rs-sdk/README.md @@ -51,7 +51,8 @@ You can find quick start example in `examples/` folder. Examples must be configu - `DocumentDeleteTransitionBuilder::with_settings(settings).sign(...)` now honors `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Native rs-sdk callers that passed delete transition fee/creation options through `PutSettings` may now see those intended values take effect on the wire instead of being silently dropped. - `DocumentCreateTransitionBuilder` and `DocumentReplaceTransitionBuilder` apply the same rule as the delete builder: `with_settings(settings).sign_with_nonce(...)` (and `.sign(...)`) now honor `settings.user_fee_increase` and `settings.state_transition_creation_options` when those builder fields were not set directly. Explicit `with_user_fee_increase` / `with_state_transition_creation_options` calls still win regardless of order. Native callers that previously passed these via `PutSettings` should expect them to take effect on the wire. - `DocumentDeleteTransitionBuilder::sign` now rolls back the allocated identity-contract nonce on pre-broadcast failure (matching `DocumentCreateTransitionBuilder::sign` / `DocumentReplaceTransitionBuilder::sign`). `DocumentDeleteTransitionBuilder::sign_with_nonce` now also runs `ensure_valid_state_transition_structure` before returning, so direct builder users get the same pre-broadcast guarantees as the shared `build_signed_document_delete_transition` helper. -- `ensure_valid_state_transition_structure` (used by the wasm-sdk `prepareDocument*` flows and the rs-sdk document create/replace/delete builders) filters `UnsupportedFeatureError` entries out of mixed structure-validation results before surfacing the remaining errors. This intentionally lets identity-based state transitions, which DPP's structure validator still reports as `UnsupportedFeatureError`, through to broadcast where execution-time validation runs. Callers that previously relied on `UnsupportedFeatureError` surfacing through this path will no longer see those entries — only the real validation failures. +- `ensure_valid_state_transition_structure` (used by the wasm-sdk `prepareDocument*` flows and the rs-sdk document create/replace/delete builders) treats `UnsupportedFeatureError` two ways, matching its two meanings in DPP. An *all-unsupported* result is treated as `Ok` so identity-based state transitions — whose DPP structure validator is still a stub that returns only `UnsupportedFeatureError` — pass through to broadcast where execution-time validation runs. A *mixed* result (unsupported entries plus real validation failures) is surfaced as `Err` containing **every** entry, including the unsupported ones; in that shape those entries are legitimate "this sub-feature is not supported on this platform version" rejections, not placeholders, and dropping them would hide user-visible diagnostic information. Callers should not assume a mixed-result error message contains only "real" entries. +- `DocumentCreateTransitionBuilder::sign_with_nonce` and `PutDocument::put_to_platform` (when `entropy = Some(...)`) now require `document.id` to equal `Document::generate_document_id_v0(dataContractId, ownerId, documentTypeName, entropy)`. A mismatch fails fast with `Error::InvalidArgument` *before* the identity-contract nonce is allocated, so a failed call does not advance the local nonce cache. Native callers that built a document with a hand-picked id and a separate entropy value (instead of letting the `Document` constructor derive both together, or calling `Document::generate_document_id_v0` with the same entropy you plan to pass to the create transition) will start seeing this rejection. The legacy `PutDocument::put_to_platform` path with `entropy = None` still rewrites the id from a freshly-generated entropy and is unaffected. diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 5f499632836..1912d51f704 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -123,19 +123,12 @@ impl DocumentCreateTransitionBuilder { /// # Returns /// /// * `Self` - The updated builder - pub fn with_settings(mut self, mut settings: PutSettings) -> Self { - if self.user_fee_increase.is_none() { - self.user_fee_increase = settings.user_fee_increase; - } - if self.state_transition_creation_options.is_none() { - self.state_transition_creation_options = settings.state_transition_creation_options; - } - // Strip the fee/creation-options fields from the stored settings so - // the dedicated builder fields are the sole source of truth at - // sign time. The remainder of `settings` flows through unchanged. - settings.user_fee_increase = None; - settings.state_transition_creation_options = None; - self.settings = Some(settings); + pub fn with_settings(mut self, settings: PutSettings) -> Self { + let stored = settings.split_dedicated_fields( + &mut self.user_fee_increase, + &mut self.state_transition_creation_options, + ); + self.settings = Some(stored); self } diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 097445fc63f..1432223ca75 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -143,19 +143,12 @@ impl DocumentDeleteTransitionBuilder { /// # Returns /// /// * `Self` - The updated builder - pub fn with_settings(mut self, mut settings: PutSettings) -> Self { - if self.user_fee_increase.is_none() { - self.user_fee_increase = settings.user_fee_increase; - } - if self.state_transition_creation_options.is_none() { - self.state_transition_creation_options = settings.state_transition_creation_options; - } - // Strip the fee/creation-options fields from the stored settings so - // the dedicated builder fields are the sole source of truth at - // sign time. The remainder of `settings` flows through unchanged. - settings.user_fee_increase = None; - settings.state_transition_creation_options = None; - self.settings = Some(settings); + pub fn with_settings(mut self, settings: PutSettings) -> Self { + let stored = settings.split_dedicated_fields( + &mut self.user_fee_increase, + &mut self.state_transition_creation_options, + ); + self.settings = Some(stored); self } diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index bb77daa77f0..4e385e56c55 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -117,19 +117,12 @@ impl DocumentReplaceTransitionBuilder { /// # Returns /// /// * `Self` - The updated builder - pub fn with_settings(mut self, mut settings: PutSettings) -> Self { - if self.user_fee_increase.is_none() { - self.user_fee_increase = settings.user_fee_increase; - } - if self.state_transition_creation_options.is_none() { - self.state_transition_creation_options = settings.state_transition_creation_options; - } - // Strip the fee/creation-options fields from the stored settings so - // the dedicated builder fields are the sole source of truth at - // sign time. The remainder of `settings` flows through unchanged. - settings.user_fee_increase = None; - settings.state_transition_creation_options = None; - self.settings = Some(settings); + pub fn with_settings(mut self, settings: PutSettings) -> Self { + let stored = settings.split_dedicated_fields( + &mut self.user_fee_increase, + &mut self.state_transition_creation_options, + ); + self.settings = Some(stored); self } diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index c9fb61b4618..646efa07230 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -122,6 +122,17 @@ pub(crate) fn ensure_revision_for_replace(revision: Option) -> Result<(), E /// branch internally on platform version) — grep for `derive_document_id_v0` /// **and** `generate_document_id_v0` to catch every call site that needs to /// migrate together. +/// +/// **TODO (consensus / platform-version migration):** when a new +/// document-id derivation is introduced via a consensus or platform-version +/// bump, this helper and every `generate_document_id_v0` call site (the +/// strict create-path id check, the legacy entropy fallback, the wasm-sdk +/// fast-path id check, and the Rust SDK tests) must move to a +/// platform-version-aware dispatch in lockstep. The current v0-only pin +/// is a deliberate choice — the strict create path rejects mismatches with +/// `Error::InvalidArgument`, so once a v1 derivation ships, native callers +/// that have hand-derived ids with the old formula will start failing here +/// until they migrate. fn derive_document_id_v0( document_type: DocumentTypeRef<'_>, owner_id: &Identifier, diff --git a/packages/rs-sdk/src/platform/transition/put_settings.rs b/packages/rs-sdk/src/platform/transition/put_settings.rs index 2d1d58c273a..7cc143a9412 100644 --- a/packages/rs-sdk/src/platform/transition/put_settings.rs +++ b/packages/rs-sdk/src/platform/transition/put_settings.rs @@ -27,3 +27,40 @@ impl From for RequestSettings { settings.request_settings } } + +impl PutSettings { + /// Split a [`PutSettings`] into the two dedicated builder fields + /// (`user_fee_increase`, `state_transition_creation_options`) and the + /// remainder of [`PutSettings`] with those two fields cleared. + /// + /// Used by the document `with_settings` implementations on the create, + /// replace, and delete builders so each builder shares one implementation + /// of the "explicit dedicated setters always win" contract: + /// + /// * if the builder's dedicated `user_fee_increase` / + /// `state_transition_creation_options` field is **already set** + /// (`Some(_)`), keep it — `with_settings` must not clobber an + /// explicit setter, regardless of call order. + /// * otherwise, move the corresponding field out of `settings` into + /// the dedicated field. + /// * in both cases, the returned `PutSettings` has both fields zeroed + /// so the dedicated builder fields are the sole source of truth at + /// sign time. Every other [`PutSettings`] field (timeouts, retry + /// behavior, nonce stale time, etc.) is preserved unchanged for + /// nonce allocation and broadcast. + pub fn split_dedicated_fields( + mut self, + dedicated_user_fee_increase: &mut Option, + dedicated_state_transition_creation_options: &mut Option, + ) -> Self { + if dedicated_user_fee_increase.is_none() { + *dedicated_user_fee_increase = self.user_fee_increase; + } + if dedicated_state_transition_creation_options.is_none() { + *dedicated_state_transition_creation_options = self.state_transition_creation_options; + } + self.user_fee_increase = None; + self.state_transition_creation_options = None; + self + } +} diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index 7fa7f911f50..ef5b3a50324 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -14,41 +14,75 @@ fn is_unsupported_feature_error(error: &ConsensusError) -> bool { ) } -/// Filter `UnsupportedFeatureError` entries out of a structure-validation -/// result and convert any remaining errors into [`Error`]. +/// Convert a structure-validation result into [`Error`], with one special +/// case for [`UnsupportedFeatureError`]. /// -/// `UnsupportedFeatureError` only signals that structure validation is not yet -/// implemented for that state transition kind, so it must never mask a real -/// validation failure. If the result becomes empty after filtering we treat it -/// as a no-op pass. -fn map_validation_result(result: SimpleConsensusValidationResult) -> Result<(), Error> { +/// `UnsupportedFeatureError` has *two* meanings in DPP: +/// +/// 1. **"Structure validation is not implemented for this state transition +/// kind"** — e.g. identity-based STs return a result that is *entirely* +/// `UnsupportedFeatureError` entries. In this case we treat the result +/// as a no-op pass so the prepare APIs can sign and broadcast these STs +/// even though their structure check is a stub. +/// 2. **"A specific feature inside an otherwise-validated ST is not +/// supported on this platform version"** — in this case the result +/// mixes `UnsupportedFeatureError` entries with real validation +/// failures. Here the unsupported entries are *not* placeholders: they +/// are legitimate rejections that explain why a particular sub-feature +/// is unavailable, and silently dropping them would discard +/// user-visible diagnostic information. +/// +/// To honor both meanings we only treat the "all errors are unsupported" +/// case as `Ok`. Once *any* non-unsupported error is present we surface +/// the result via the existing `From for +/// Error` conversion — which keeps the first error as a *typed* +/// `ConsensusError` so callers can pattern-match on it. To avoid the +/// conversion picking an `UnsupportedFeatureError` placeholder when a +/// real failure is also present, we first reorder the error list so the +/// first non-`UnsupportedFeatureError` entry is primary. +fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> { if result.is_valid() { return Ok(()); } - let real_errors: Vec = result - .errors - .into_iter() - .filter(|e| !is_unsupported_feature_error(e)) - .collect(); - - if real_errors.is_empty() { - Ok(()) - } else { - Err(SimpleConsensusValidationResult::new_with_errors(real_errors).into()) + if result.errors.iter().all(is_unsupported_feature_error) { + return Ok(()); } + + // Mixed `UnsupportedFeatureError` + real-error case. The default + // `From for Error` conversion keeps + // the *first* error as a typed `ConsensusError`. Stable-partition the + // list so real failures come first, ensuring the typed error returned + // is the most actionable one and not an `UnsupportedFeatureError` + // placeholder. We deliberately use the existing `From` conversion so + // the returned `Error` preserves the typed `ConsensusError` variant + // for downstream pattern-matching, instead of being flattened into a + // `ProtocolError::Generic` string. + result.errors.sort_by_key(|e| { + if is_unsupported_feature_error(e) { + 1 + } else { + 0 + } + }); + Err(Error::from(result)) } /// 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. +/// `UnsupportedFeatureError` has two meanings in DPP — see +/// [`map_validation_result`] for the full discussion. In short: /// -/// When the result mixes `UnsupportedFeatureError`s with other errors, the -/// unsupported variants are filtered out so the surfaced error is always a -/// real validation problem rather than a "not implemented" placeholder. +/// * an all-unsupported result is treated as `Ok` because DPP uses that +/// shape as a "structure validation is not implemented for this state +/// transition kind" sentinel (e.g. identity-based STs). The platform +/// will still perform validation during execution. +/// * a result that mixes `UnsupportedFeatureError` with real errors is +/// surfaced as an `Err` via the existing +/// `From for Error` conversion, with +/// the real failures reordered first so the returned typed +/// `ConsensusError` is the actionable one (not an +/// `UnsupportedFeatureError` placeholder). pub fn ensure_valid_state_transition_structure( state_transition: &StateTransition, platform_version: &PlatformVersion, @@ -94,19 +128,61 @@ mod tests { assert!(format!("{err}").contains("bad value")); } - /// When errors mix unsupported with real ones, the real one wins — - /// without this filtering, the first-error conversion could surface the - /// UnsupportedFeatureError and incorrectly let the transition through. + /// When errors mix unsupported with real ones we return an `Err` + /// via the existing `From for Error` + /// conversion, with real failures reordered first so the typed + /// `ConsensusError` returned is the actionable one — not an + /// `UnsupportedFeatureError` placeholder. #[test] - fn mixed_errors_skip_unsupported_and_return_real_error() { + fn mixed_errors_promote_real_error_to_primary_typed_error() { let result = SimpleConsensusValidationResult::new_with_errors(vec![ unsupported_error(), value_error("real failure"), ]); - let err = map_validation_result(result).expect_err("expected real error"); + let err = map_validation_result(result).expect_err("expected error"); + let rendered = format!("{err}"); assert!( - format!("{err}").contains("real failure"), - "expected real-failure message, got: {err}" + rendered.contains("real failure"), + "expected real-failure message, got: {rendered}" ); + + // The conversion must preserve the typed `ConsensusError` variant + // (a `ValueError` here) rather than wrapping the rendered text in + // a `ProtocolError::Generic`. Pattern-matching on the typed + // variant is what downstream callers rely on. + match err { + Error::Protocol(dpp::ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ValueError(_)) => {} + other => panic!("expected ValueError typed variant, got: {other:?}"), + }, + other => panic!("expected typed ConsensusError, got: {other:?}"), + } + } + + /// Real errors are reordered to the front even when they appear after + /// `UnsupportedFeatureError` entries in the input list, so the typed + /// `ConsensusError` returned by the `From` conversion is always the + /// actionable failure, never an unsupported-feature placeholder. + #[test] + fn mixed_errors_reorder_real_failure_before_unsupported() { + let result = SimpleConsensusValidationResult::new_with_errors(vec![ + unsupported_error(), + unsupported_error(), + value_error("primary failure"), + unsupported_error(), + ]); + let err = map_validation_result(result).expect_err("expected error"); + match err { + Error::Protocol(dpp::ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ValueError(ref ve)) => { + assert!( + ve.to_string().contains("primary failure"), + "expected primary failure, got: {ve}" + ); + } + other => panic!("expected ValueError typed variant, got: {other:?}"), + }, + other => panic!("expected typed ConsensusError, got: {other:?}"), + } } } diff --git a/packages/wasm-dpp2/src/data_contract/document/model.rs b/packages/wasm-dpp2/src/data_contract/document/model.rs index 27cf5006d48..7e833311104 100644 --- a/packages/wasm-dpp2/src/data_contract/document/model.rs +++ b/packages/wasm-dpp2/src/data_contract/document/model.rs @@ -343,6 +343,15 @@ impl DocumentWasm { self.document_type_name.clone() } + /// Optional creator identity. Present on documents from contracts whose + /// document type retains the original creator's identifier; `None` for + /// fresh documents built from the `Document` constructor (which does + /// not set `creator_id`). + #[wasm_bindgen(getter = creatorId)] + pub fn creator_id(&self) -> Option { + self.document.creator_id().map(Into::into) + } + #[wasm_bindgen(setter=id)] pub fn set_id(&mut self, id: IdentifierLikeJs) -> WasmDppResult<()> { self.document.set_id(id.try_into()?); diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 367652503f7..2e6b19e9089 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -69,13 +69,45 @@ //! separate entropy value, you must now either let the `Document` //! constructor derive both together (its default behavior) or call //! `Document.generateId(...)` with the same entropy you intend to use. +//! +//! ## Trusted wasm-dpp2 producers: `identityKey` and `signer` +//! +//! Both the prepare and one-shot APIs deliberately accept `identityKey` +//! and `signer` only as their real wasm-dpp2 class instances and read +//! them through `IdentityPublicKeyWasm::try_from_options` / +//! `IdentitySignerWasm::try_from_options`. Those conversions trust the +//! wasm-bindgen `__wbg_ptr` field on the value. +//! +//! This is an intentional carve-out from the structural-extraction +//! hardening applied to `document` / `paymentTokenContractId` / etc., +//! because: +//! +//! * `IdentitySigner` is an opaque handle that owns key material plus +//! the in-Rust signing-callback state. There is no public JS field +//! surface to reconstruct it from — by design, callers must not have +//! direct access to the raw private key bytes. Any "structural" +//! extraction would have to invent a new producer API for the signer, +//! which would be a strictly weaker security boundary than just +//! refusing non-wasm-dpp2 inputs. +//! * `IdentityPublicKey` is conceptually copyable, but it is only ever +//! produced inside the SDK (e.g. fetched from Platform, parsed from a +//! contract, derived from the signer) and immediately handed back to +//! the SDK for the same call. It never crosses an untrusted boundary +//! in the way `document` does (which callers freely build from +//! JSON/object literals). +//! +//! Callers must therefore pass wasm-dpp2 class instances produced by +//! this SDK (or wasm-dpp2 directly). Passing a forged JS object with a +//! spoofed `__wbg_ptr` is out of scope of this API's safety guarantees; +//! the structural extraction applied elsewhere closes that hole only on +//! the inputs that legitimately accept plain-object shapes. use crate::error::WasmSdkError; use crate::sdk::WasmSdk; use crate::settings::PutSettingsInput; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentType; -use dash_sdk::dpp::document::{Document, DocumentV0Getters, INITIAL_REVISION}; +use dash_sdk::dpp::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; @@ -99,8 +131,9 @@ use wasm_dpp2::state_transitions::batch::token_payment_info::{ TokenPaymentInfoOptionsJs, TokenPaymentInfoWasm, }; use wasm_dpp2::utils::{ - get_class_type, try_from_options_optional, try_from_options_with, try_to_string, try_to_u64, - IntoWasm, JsValueExt, + get_class_type, try_from_options_optional, try_from_options_optional_with, + try_from_options_with, try_to_fixed_bytes, try_to_string, try_to_u32, try_to_u64, JsValueExt, + ToSerdeJSONExt, }; use wasm_dpp2::IdentitySignerWasm; use wasm_dpp2::StateTransitionWasm; @@ -168,21 +201,26 @@ fn try_from_options_optional_token_payment_info( // values. A forged object can spoof the public `__type` getter/string, // but it cannot force us to dereference an arbitrary wasm pointer when // we only copy public fields into a fresh options bag. + // Both shapes funnel through a fresh options bag built from safe, + // structural reads. In particular, `paymentTokenContractId` is read + // via `extract_optional_identifier_property` and re-attached as a + // raw byte array, never forwarded as the original `JsValue`. That + // closes the same `__wbg_ptr`-trust hole `extract_identifier_property` + // protects against: a forged object can spoof a public `__type` / + // `toString` / `toJSON` surface, but it cannot smuggle an arbitrary + // wasm pointer into the eventual `TokenPaymentInfoWasm::constructor` + // parse because we only ever forward extracted bytes. let class_type = get_class_type(&token_payment_info_value) .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?; - let token_payment_info = match class_type.as_str() { - "TokenPaymentInfo" => TokenPaymentInfoWasm::constructor( - token_payment_info_options_from_public_fields(&token_payment_info_value)?, - ) - .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?, + let options = match class_type.as_str() { + "TokenPaymentInfo" | "" => { + token_payment_info_options_from_public_fields(&token_payment_info_value)? + } // Plain object path: no `__type` getter is set up, so // `get_class_type` returns `Ok("")` (empty string default in // `JsValue::as_string().unwrap_or_default()`). Treat the empty - // string the same as "no class marker present". - "" => TokenPaymentInfoWasm::constructor( - token_payment_info_value.unchecked_into::(), - ) - .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?, + // string the same as "no class marker present". Any other + // class marker is rejected below. other => { return Err(WasmSdkError::invalid_argument(format!( "tokenPaymentInfo must be a plain DocumentTokenPaymentInfo options object \ @@ -190,6 +228,8 @@ fn try_from_options_optional_token_payment_info( ))); } }; + let token_payment_info = TokenPaymentInfoWasm::constructor(options) + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?; Ok(Some(token_payment_info.into())) } @@ -198,8 +238,33 @@ fn token_payment_info_options_from_public_fields( value: &JsValue, ) -> Result { let options = js_sys::Object::new(); + + // `paymentTokenContractId` is identifier-shaped and accepts class + // instances, byte arrays, or base58/hex strings. Run it through the + // safe structural extractor so a forged `Identifier`-shaped JS object + // cannot smuggle a raw `__wbg_ptr` through to the constructor parse. + if let Some(payment_token_contract_id) = + extract_optional_identifier_property(value, "paymentTokenContractId")? + { + let buffer: [u8; 32] = payment_token_contract_id.to_buffer(); + let bytes = js_sys::Uint8Array::from(&buffer[..]); + Reflect::set( + &options, + &JsValue::from_str("paymentTokenContractId"), + &bytes.into(), + ) + .map_err(|err| { + WasmSdkError::invalid_argument(format!( + "failed to copy tokenPaymentInfo.paymentTokenContractId: {}", + err.error_message() + )) + })?; + } + + // The remaining fields are primitives / enums — copying them + // structurally is safe because none of them feed into a + // `__wbg_ptr`-trusting conversion path. for field in [ - "paymentTokenContractId", "tokenContractPosition", "minimumTokenCost", "maximumTokenCost", @@ -304,8 +369,11 @@ impl WasmSdk { &self, options: DocumentCreateOptionsJs, ) -> Result<(), WasmSdkError> { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never + // the wasm-bindgen pointer. See `extract_prepare_document` for + // the security rationale; the one-shot create path applies the + // same hardening as the prepare path. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); ensure_document_create_revision(document.revision(), "documentReplace")?; @@ -447,8 +515,11 @@ impl WasmSdk { &self, options: DocumentReplaceOptionsJs, ) -> Result<(), WasmSdkError> { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never + // the wasm-bindgen pointer. See `extract_prepare_document` for + // the security rationale; the one-shot replace path applies the + // same hardening as the prepare path. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); ensure_document_replace_revision(document.revision(), "documentCreate")?; @@ -567,43 +638,11 @@ impl WasmSdk { &self, options: DocumentDeleteOptionsJs, ) -> Result<(), WasmSdkError> { - // Extract document field - can be either a Document instance or plain object - let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document")) - .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; - - if document_js.is_undefined() || document_js.is_null() { - return Err(WasmSdkError::invalid_argument("document is required")); - } - - // Check if it's a Document instance or a plain object with fields - let (document_id, owner_id, contract_id, document_type_name): ( - Identifier, - Identifier, - Identifier, - String, - ) = if get_class_type(&document_js).ok().as_deref() == Some("Document") { - // It's a Document instance - extract fields from it - let doc: DocumentWasm = document_js - .to_wasm::("Document") - .map(|boxed| (*boxed).clone())?; - let doc_inner: Document = doc.clone().into(); - ( - doc.id().into(), - doc_inner.owner_id(), - doc.data_contract_id().into(), - doc.document_type_name(), - ) - } else { - // It's a plain object - extract individual fields - ( - IdentifierWasm::try_from_options(&document_js, "id")?.into(), - IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), - IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), - try_from_options_with(&document_js, "documentTypeName", |v| { - try_to_string(v, "documentTypeName") - })?, - ) - }; + // Extract the four delete identifiers via public/structural JS + // fields only — never the wasm-bindgen pointer. See + // `extract_delete_identifiers` for the security rationale. + let (document_id, owner_id, contract_id, document_type_name) = + extract_delete_identifiers(&options)?; // Extract identity key from options let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; @@ -677,9 +716,26 @@ export interface PrepareDocumentCreateOptions { * entropy explicitly, keep them consistent. */ document: Document; - /** The identity public key to use for signing. */ + /** + * The identity public key to use for signing. + * + * Must be a wasm-dpp2 `IdentityPublicKey` instance produced by this SDK + * (e.g. fetched from Platform or derived from the signer). This field + * is read via the trusted wasm-bindgen handle and is **not** validated + * structurally — see the module-level "Trusted wasm-dpp2 producers" + * note for why this carve-out exists. + */ identityKey: IdentityPublicKey; - /** Signer containing the private key for the identity key. */ + /** + * Signer containing the private key for the identity key. + * + * Must be a wasm-dpp2 `IdentitySigner` instance. `IdentitySigner` is + * an opaque handle that owns private key material and signing-callback + * state, so it has no structural JS-field shape that could be + * safely reconstructed; this input is therefore intentionally trusted + * via its wasm-bindgen handle. See the module-level "Trusted wasm-dpp2 + * producers" note for the full rationale. + */ signer: IdentitySigner; /** Optional token payment agreement for document types with tokenCost.create. */ tokenPaymentInfo?: DocumentTokenPaymentInfo; @@ -722,8 +778,10 @@ impl WasmSdk { &self, options: PrepareDocumentCreateOptionsJs, ) -> Result { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never the + // wasm-bindgen pointer. See `extract_prepare_document` for the + // security rationale. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); ensure_document_create_revision(document.revision(), "prepareDocumentReplace")?; @@ -811,9 +869,26 @@ const PREPARE_DOCUMENT_REPLACE_OPTIONS_TS: &'static str = r#" export interface PrepareDocumentReplaceOptions { /** The document with updated data (same ID, incremented revision). */ document: Document; - /** The identity public key to use for signing. */ + /** + * The identity public key to use for signing. + * + * Must be a wasm-dpp2 `IdentityPublicKey` instance produced by this SDK + * (e.g. fetched from Platform or derived from the signer). This field + * is read via the trusted wasm-bindgen handle and is **not** validated + * structurally — see the module-level "Trusted wasm-dpp2 producers" + * note for why this carve-out exists. + */ identityKey: IdentityPublicKey; - /** Signer containing the private key for the identity key. */ + /** + * Signer containing the private key for the identity key. + * + * Must be a wasm-dpp2 `IdentitySigner` instance. `IdentitySigner` is + * an opaque handle that owns private key material and signing-callback + * state, so it has no structural JS-field shape that could be + * safely reconstructed; this input is therefore intentionally trusted + * via its wasm-bindgen handle. See the module-level "Trusted wasm-dpp2 + * producers" note for the full rationale. + */ signer: IdentitySigner; /** Optional token payment agreement for document types with tokenCost.replace. */ tokenPaymentInfo?: DocumentTokenPaymentInfo; @@ -849,8 +924,10 @@ impl WasmSdk { &self, options: PrepareDocumentReplaceOptionsJs, ) -> Result { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never the + // wasm-bindgen pointer. See `extract_prepare_document` for the + // security rationale. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); ensure_document_replace_revision(document.revision(), "prepareDocumentCreate")?; @@ -918,9 +995,26 @@ export interface PrepareDocumentDeleteOptions { dataContractId: IdentifierLike; documentTypeName: string; }; - /** The identity public key to use for signing. */ + /** + * The identity public key to use for signing. + * + * Must be a wasm-dpp2 `IdentityPublicKey` instance produced by this SDK + * (e.g. fetched from Platform or derived from the signer). This field + * is read via the trusted wasm-bindgen handle and is **not** validated + * structurally — see the module-level "Trusted wasm-dpp2 producers" + * note for why this carve-out exists. + */ identityKey: IdentityPublicKey; - /** Signer containing the private key for the identity key. */ + /** + * Signer containing the private key for the identity key. + * + * Must be a wasm-dpp2 `IdentitySigner` instance. `IdentitySigner` is + * an opaque handle that owns private key material and signing-callback + * state, so it has no structural JS-field shape that could be + * safely reconstructed; this input is therefore intentionally trusted + * via its wasm-bindgen handle. See the module-level "Trusted wasm-dpp2 + * producers" note for the full rationale. + */ signer: IdentitySigner; /** Optional token payment agreement for document types with tokenCost.delete. */ tokenPaymentInfo?: DocumentTokenPaymentInfo; @@ -956,41 +1050,11 @@ impl WasmSdk { &self, options: PrepareDocumentDeleteOptionsJs, ) -> Result { - // Extract document field - can be either a Document instance or plain object - let document_js = js_sys::Reflect::get(&options, &JsValue::from_str("document")) - .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; - - if document_js.is_undefined() || document_js.is_null() { - return Err(WasmSdkError::invalid_argument("document is required")); - } - - // Check if it's a Document instance or a plain object with fields - let (document_id, owner_id, contract_id, document_type_name): ( - Identifier, - Identifier, - Identifier, - String, - ) = if get_class_type(&document_js).ok().as_deref() == Some("Document") { - let doc: DocumentWasm = document_js - .to_wasm::("Document") - .map(|boxed| (*boxed).clone())?; - let doc_inner: Document = doc.clone().into(); - ( - doc.id().into(), - doc_inner.owner_id(), - doc.data_contract_id().into(), - doc.document_type_name(), - ) - } else { - ( - IdentifierWasm::try_from_options(&document_js, "id")?.into(), - IdentifierWasm::try_from_options(&document_js, "ownerId")?.into(), - IdentifierWasm::try_from_options(&document_js, "dataContractId")?.into(), - try_from_options_with(&document_js, "documentTypeName", |v| { - try_to_string(v, "documentTypeName") - })?, - ) - }; + // Extract the four delete identifiers via public/structural JS + // fields only — never the wasm-bindgen pointer. See + // `extract_delete_identifiers` for the security rationale. + let (document_id, owner_id, contract_id, document_type_name) = + extract_delete_identifiers(&options)?; // Extract identity key from options let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; @@ -1111,8 +1175,10 @@ impl WasmSdk { &self, options: DocumentTransferOptionsJs, ) -> Result<(), WasmSdkError> { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never + // the wasm-bindgen pointer. See `extract_prepare_document` for + // the security rationale. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); // Get metadata from document @@ -1241,8 +1307,10 @@ impl WasmSdk { &self, options: DocumentPurchaseOptionsJs, ) -> Result<(), WasmSdkError> { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never + // the wasm-bindgen pointer. See `extract_prepare_document` for + // the security rationale. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); // Get metadata from document @@ -1361,8 +1429,10 @@ impl WasmSdk { &self, options: DocumentSetPriceOptionsJs, ) -> Result<(), WasmSdkError> { - // Extract document from options - let document_wasm = DocumentWasm::try_from_options(&options, "document")?; + // Extract document via public/structural JS fields only — never + // the wasm-bindgen pointer. See `extract_prepare_document` for + // the security rationale. + let document_wasm = extract_prepare_document(&options)?; let document: Document = document_wasm.clone().into(); // Get metadata from document @@ -1411,6 +1481,292 @@ impl WasmSdk { // Helper Functions // ============================================================================ +/// Extract an identifier-shaped property from a JS object using only +/// public/structural JS reads, never the wasm-bindgen `__wbg_ptr` field. +/// +/// `IdentifierWasm::try_from(&JsValue)` (and therefore +/// `IdentifierWasm::try_from_options`) falls through to +/// `to_wasm::("Identifier")` whenever the input "looks like" +/// an `Identifier` class instance, which dereferences the wasm-bindgen +/// `__wbg_ptr` field as a Rust pointer. A forged JS object can advertise +/// `__type === "Identifier"` while handing us an arbitrary numeric +/// `__wbg_ptr`, so trusting that pointer on untrusted inputs is unsound — +/// the same rationale that motivates `extract_prepare_document` / +/// `extract_delete_identifiers`. +/// +/// Accepted public shapes: +/// * a Base58 / hex string (via `IdentifierWasm::try_from(&str)`), +/// * a `Uint8Array` or `Array` of bytes (via `IdentifierWasm::try_from(&[u8])`), +/// * an `Identifier` class instance — read via its **public** JS methods +/// (`toBytes()`, then `toString()` / `toJSON()`), never via `__wbg_ptr`. +fn extract_identifier_property( + container: &JsValue, + property_name: &str, +) -> Result { + let value = Reflect::get(container, &JsValue::from_str(property_name)).map_err(|err| { + WasmSdkError::invalid_argument(format!( + "failed to read '{}' from options: {:?}", + property_name, err + )) + })?; + + if value.is_undefined() || value.is_null() { + return Err(WasmSdkError::invalid_argument(format!( + "'{}' is required", + property_name + ))); + } + + // String: Base58 or hex. + if let Some(s) = value.as_string() { + return identifier_from_str(&s, property_name); + } + + // Uint8Array / typed-array byte source. + if value.is_instance_of::() { + let bytes = js_sys::Uint8Array::from(value.clone()).to_vec(); + return identifier_from_bytes(&bytes, property_name); + } + + // Plain JS array of byte-like numbers. + if js_sys::Array::is_array(&value) { + let bytes = js_sys::Uint8Array::from(value.clone()).to_vec(); + return identifier_from_bytes(&bytes, property_name); + } + + // Class-instance case: read via *public* JS methods only. We deliberately + // do not look at `__type` / `__wbg_ptr`. `toBytes()` is not present on + // `Object.prototype`, so it serves as a clean discriminator for the + // real `Identifier` class instance shape; we fall back to + // `toString()` / `toJSON()` (both yield the base58 form on real + // `Identifier` instances). + if value.is_object() { + if let Some(bytes) = call_no_arg_method_returning_uint8array(&value, "toBytes") { + return identifier_from_bytes(&bytes, property_name); + } + if let Some(s) = call_no_arg_method_returning_string(&value, "toString") { + // Skip the unhelpful default `Object.prototype.toString` result + // ("[object Object]"), which is never a valid identifier anyway + // but produces a confusing parse error if we let it through. + if !s.starts_with('[') { + return identifier_from_str(&s, property_name); + } + } + if let Some(s) = call_no_arg_method_returning_string(&value, "toJSON") { + return identifier_from_str(&s, property_name); + } + } + + Err(WasmSdkError::invalid_argument(format!( + "'{}' must be an Identifier, Uint8Array, array, or base58/hex string", + property_name + ))) +} + +/// Same structural extraction as [`extract_identifier_property`], but +/// returns `Ok(None)` if the property is missing/`undefined`/`null` +/// instead of erroring. Use for genuinely optional identifier fields like +/// `creatorId` and `paymentTokenContractId` so a missing value is not +/// conflated with a malformed one. +fn extract_optional_identifier_property( + container: &JsValue, + property_name: &str, +) -> Result, WasmSdkError> { + let value = Reflect::get(container, &JsValue::from_str(property_name)).map_err(|err| { + WasmSdkError::invalid_argument(format!( + "failed to read '{}' from options: {:?}", + property_name, err + )) + })?; + + if value.is_undefined() || value.is_null() { + return Ok(None); + } + + extract_identifier_property(container, property_name).map(Some) +} + +fn identifier_from_str(s: &str, property_name: &str) -> Result { + IdentifierWasm::try_from(s) + .map(Into::into) + .map_err(|err| WasmSdkError::invalid_argument(format!("'{}': {}", property_name, err))) +} + +fn identifier_from_bytes(bytes: &[u8], property_name: &str) -> Result { + IdentifierWasm::try_from(bytes) + .map(Into::into) + .map_err(|err| WasmSdkError::invalid_argument(format!("'{}': {}", property_name, err))) +} + +fn call_no_arg_method_returning_uint8array(obj: &JsValue, method: &str) -> Option> { + let func = Reflect::get(obj, &JsValue::from_str(method)).ok()?; + if !func.is_function() { + return None; + } + let func: js_sys::Function = func.unchecked_into(); + let ret = func.call0(obj).ok()?; + if ret.is_undefined() || ret.is_null() { + return None; + } + ret.dyn_into::() + .ok() + .map(|arr| arr.to_vec()) +} + +fn call_no_arg_method_returning_string(obj: &JsValue, method: &str) -> Option { + let func = Reflect::get(obj, &JsValue::from_str(method)).ok()?; + if !func.is_function() { + return None; + } + let func: js_sys::Function = func.unchecked_into(); + func.call0(obj).ok()?.as_string() +} + +/// Extract the four identifiers needed to build a document delete transition +/// (`id`, `ownerId`, `dataContractId`, `documentTypeName`) from the `document` +/// field of a delete-options object. +/// +/// We accept *both* a `wasm-dpp2` `Document` class instance and a plain +/// `{ id, ownerId, dataContractId, documentTypeName }` options bag — but the +/// extraction itself reads only the public JS getters/fields on the value. +/// We deliberately do **not** call `DocumentWasm::try_from` / +/// `to_wasm::("Document")` here: those paths read the +/// wasm-bindgen `__wbg_ptr` field and dereference it as a Rust pointer. A +/// forged JS object can advertise `__type === "Document"` but still hand us +/// an arbitrary numeric `__wbg_ptr`, so trusting the pointer on untrusted +/// inputs is unsound. Using only `id`, `ownerId`, `dataContractId`, and +/// `documentTypeName` (which `Document` exposes as ordinary +/// `#[wasm_bindgen(getter = ...)]` accessors) keeps the read structural and +/// safe for both shapes. +fn extract_delete_identifiers( + options: &JsValue, +) -> Result<(Identifier, Identifier, Identifier, String), WasmSdkError> { + let document_js = js_sys::Reflect::get(options, &JsValue::from_str("document")) + .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; + + if document_js.is_undefined() || document_js.is_null() { + return Err(WasmSdkError::invalid_argument("document is required")); + } + + let document_id = extract_identifier_property(&document_js, "id")?; + let owner_id = extract_identifier_property(&document_js, "ownerId")?; + let contract_id = extract_identifier_property(&document_js, "dataContractId")?; + let document_type_name = try_from_options_with(&document_js, "documentTypeName", |v| { + try_to_string(v, "documentTypeName") + })?; + + Ok((document_id, owner_id, contract_id, document_type_name)) +} + +/// Extract a `DocumentWasm` from the `document` field of a prepare-options +/// object via *public/structural JS reads only*, building a fresh `Document` +/// in Rust from those reads. +/// +/// `prepareDocumentCreate` / `prepareDocumentReplace` accept either: +/// * a `wasm-dpp2` `Document` class instance (whose getters return +/// `IdentifierWasm`, `bigint`, `Uint8Array`, …), or +/// * a plain `{ id, ownerId, dataContractId, documentTypeName, properties, +/// revision?, entropy?, createdAt?, … }` options bag. +/// +/// We deliberately do **not** call `DocumentWasm::try_from` / +/// `to_wasm::("Document")` here: those paths read the +/// wasm-bindgen `__wbg_ptr` field on the value and dereference it as a Rust +/// pointer. A forged JS object can advertise `__type === "Document"` but +/// still hand us an arbitrary numeric `__wbg_ptr`, so trusting that pointer +/// on untrusted inputs is unsound. Real `Document` instances expose every +/// field below via ordinary `#[wasm_bindgen(getter = ...)]` accessors, so +/// `Reflect::get` works the same way for both shapes — but the bytes we +/// build the `Document` from are always derived from the public surface, +/// never from a caller-supplied pointer. +fn extract_prepare_document(options: &JsValue) -> Result { + let document_js = Reflect::get(options, &JsValue::from_str("document")) + .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; + + if document_js.is_undefined() || document_js.is_null() { + return Err(WasmSdkError::invalid_argument("document is required")); + } + + let id = extract_identifier_property(&document_js, "id")?; + let owner_id = extract_identifier_property(&document_js, "ownerId")?; + let contract_id = extract_identifier_property(&document_js, "dataContractId")?; + let document_type_name = try_from_options_with(&document_js, "documentTypeName", |v| { + try_to_string(v, "documentTypeName") + })?; + let properties = try_from_options_with(&document_js, "properties", |v| { + v.with_serde_to_platform_value_map() + })?; + + let revision = + try_from_options_optional_with(&document_js, "revision", |v| try_to_u64(v, "revision"))?; + + let entropy = try_from_options_optional_with(&document_js, "entropy", |v| { + try_to_fixed_bytes::<32>(v.clone(), "entropy") + })?; + + let created_at = + try_from_options_optional_with(&document_js, "createdAt", |v| try_to_u64(v, "createdAt"))?; + let updated_at = + try_from_options_optional_with(&document_js, "updatedAt", |v| try_to_u64(v, "updatedAt"))?; + let transferred_at = try_from_options_optional_with(&document_js, "transferredAt", |v| { + try_to_u64(v, "transferredAt") + })?; + let created_at_block_height = + try_from_options_optional_with(&document_js, "createdAtBlockHeight", |v| { + try_to_u64(v, "createdAtBlockHeight") + })?; + let updated_at_block_height = + try_from_options_optional_with(&document_js, "updatedAtBlockHeight", |v| { + try_to_u64(v, "updatedAtBlockHeight") + })?; + let transferred_at_block_height = + try_from_options_optional_with(&document_js, "transferredAtBlockHeight", |v| { + try_to_u64(v, "transferredAtBlockHeight") + })?; + let created_at_core_block_height = + try_from_options_optional_with(&document_js, "createdAtCoreBlockHeight", |v| { + try_to_u32(v, "createdAtCoreBlockHeight") + })?; + let updated_at_core_block_height = + try_from_options_optional_with(&document_js, "updatedAtCoreBlockHeight", |v| { + try_to_u32(v, "updatedAtCoreBlockHeight") + })?; + let transferred_at_core_block_height = + try_from_options_optional_with(&document_js, "transferredAtCoreBlockHeight", |v| { + try_to_u32(v, "transferredAtCoreBlockHeight") + })?; + + // `creatorId` is optional and read structurally. On a real + // `wasm-dpp2` `Document` instance it comes from the public + // `creatorId` getter; on plain-object inputs callers can pass it + // explicitly. Missing / null / undefined yields `None`, matching the + // `DocumentWasm` constructor's default. + let creator_id = extract_optional_identifier_property(&document_js, "creatorId")?; + + let document = Document::V0(DocumentV0 { + id, + owner_id, + properties, + revision, + created_at, + updated_at, + transferred_at, + created_at_block_height, + updated_at_block_height, + transferred_at_block_height, + created_at_core_block_height, + updated_at_core_block_height, + transferred_at_core_block_height, + creator_id, + }); + + Ok(DocumentWasm::new( + document, + contract_id, + document_type_name, + entropy, + )) +} + /// Fast-fail verification that `document.id` matches the v0 document-id /// derivation for `(contract_id, owner_id, document_type_name, entropy)`. /// diff --git a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts index 2de04065c75..249c9d4acee 100644 --- a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -135,10 +135,41 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); + it('accepts a plain-object document and applies the same validation', async () => { + // Regression test for the safe structural extraction path. The + // prepare API must accept a plain `{...}` document shape *without* + // dereferencing wasm-bindgen internals — but it must still enforce + // the same revision guard a real `Document` instance hits. Hand the + // SDK a plain object with revision 2 and assert it routes to the + // revision guard, not the `__wbg_ptr` fast path. + const { signer, identityKey } = buildSigner(); + const document = { + id: DUMMY_ID, + ownerId: DUMMY_ID_2, + dataContractId: DUMMY_ID, + documentTypeName: 'note', + properties: { message: 'hello' }, + revision: 2, + entropy: new Uint8Array(32), + }; + + try { + await client.prepareDocumentCreate({ document, identityKey, signer }); + expect.fail('expected prepareDocumentCreate to reject revision > 1'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision/i); + expect(e.message).to.match(/prepareDocumentReplace/); + } + }); + it('rejects a document with revision 0 (would silently be a replace)', async () => { - // `build_document_create_or_replace_transition` routes any `revision != INITIAL_REVISION` - // to the replace branch, so `revision = 0` must be rejected too — otherwise it would - // silently produce a replace transition instead of a create. + // Revision 0 is invalid for *both* create and replace, so the shared + // rejection message must not point users at the sibling API (which + // would also reject). It must say "revision 0 is invalid for both + // create and replace" so callers see the always-invalid value + // explicitly instead of being routed in a loop. const { signer, identityKey } = buildSigner(); const document = buildDocument({ revision: 0 }); @@ -148,8 +179,9 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } catch (e) { expect(e).to.be.instanceOf(sdk.WasmSdkError); expect(e.name).to.equal('InvalidArgument'); - expect(e.message).to.match(/revision/i); - expect(e.message).to.match(/prepareDocumentReplace/); + expect(e.message).to.match(/revision 0 is invalid for both create and replace/); + expect(e.message).to.not.match(/prepareDocumentReplace/); + expect(e.message).to.not.match(/prepareDocumentCreate/); } }); }); @@ -173,12 +205,46 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati }); it('rejects a document with revision 0', async () => { + // Revision 0 is invalid for *both* create and replace, so the shared + // rejection message must not point users at the sibling API (which + // would also reject). It must say "revision 0 is invalid for both + // create and replace" so callers see the always-invalid value + // explicitly instead of being routed in a loop. const { signer, identityKey } = buildSigner(); const document = buildDocument({ revision: 0 }); try { await client.prepareDocumentReplace({ document, identityKey, signer }); expect.fail('expected prepareDocumentReplace to reject revision 0'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/revision 0 is invalid for both create and replace/); + expect(e.message).to.not.match(/prepareDocumentCreate/); + expect(e.message).to.not.match(/prepareDocumentReplace/); + } + }); + + it('accepts a plain-object document and applies the same validation', async () => { + // Regression test for the safe structural extraction path. The + // prepare API must accept a plain `{...}` document shape *without* + // dereferencing wasm-bindgen internals — but it must still enforce + // the same revision guard a real `Document` instance hits. Hand the + // SDK a plain object with revision 1 and assert it routes to the + // replace revision guard. + const { signer, identityKey } = buildSigner(); + const document = { + id: DUMMY_ID, + ownerId: DUMMY_ID_2, + dataContractId: DUMMY_ID, + documentTypeName: 'note', + properties: { message: 'hello' }, + revision: 1, + }; + + try { + await client.prepareDocumentReplace({ document, identityKey, signer }); + expect.fail('expected prepareDocumentReplace to reject revision 1'); } catch (e) { expect(e).to.be.instanceOf(sdk.WasmSdkError); expect(e.name).to.equal('InvalidArgument'); @@ -335,6 +401,11 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati describe('documentCreate()/documentReplace()', () => { it('documentCreate rejects a document with revision 0', async () => { + // Revision 0 is invalid for *both* create and replace, so the shared + // rejection message must not point users at the sibling API (which + // would also reject). It must say "revision 0 is invalid for both + // create and replace" so callers see the always-invalid value + // explicitly instead of being routed in a loop. const { signer, identityKey } = buildSigner(); const document = buildDocument({ revision: 0 }); @@ -344,8 +415,9 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } catch (e) { expect(e).to.be.instanceOf(sdk.WasmSdkError); expect(e.name).to.equal('InvalidArgument'); - expect(e.message).to.match(/revision/i); - expect(e.message).to.match(/documentReplace/); + expect(e.message).to.match(/revision 0 is invalid for both create and replace/); + expect(e.message).to.not.match(/documentCreate/); + expect(e.message).to.not.match(/documentReplace/); } }); From a523a1ec5e16352fc68197b386266b21748ff3d7 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 13 May 2026 17:34:50 -0500 Subject: [PATCH 22/24] fix(sdk): address document prepare review follow-up --- .../dpp_versions/dpp_document_versions/mod.rs | 6 + .../dpp_versions/dpp_document_versions/v1.rs | 1 + .../dpp_versions/dpp_document_versions/v2.rs | 1 + .../dpp_versions/dpp_document_versions/v3.rs | 1 + .../platform/documents/transitions/create.rs | 93 +++++++- .../platform/documents/transitions/delete.rs | 93 +++++++- .../platform/documents/transitions/replace.rs | 84 ++++++- .../src/platform/transition/put_document.rs | 222 +++++++++++++----- .../src/platform/transition/put_settings.rs | 123 +++++++++- .../src/platform/transition/validation.rs | 202 ++++++++++++---- .../src/state_transitions/document.rs | 122 ++++++++-- 11 files changed, 793 insertions(+), 155 deletions(-) diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs index 22148d7eeaf..1d051afe980 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs @@ -22,4 +22,10 @@ pub struct DocumentMethodVersions { pub get_raw_for_contract: FeatureVersion, pub get_raw_for_document_type: FeatureVersion, pub try_into_asset_unlock_base_transaction_info: FeatureVersion, + /// Version of the document-id derivation used by client-side helpers + /// (e.g. `rs-sdk` put-document strict create path, wasm-sdk + /// `prepareDocumentCreate` fast-path id check) when computing + /// `(contract_id, owner_id, document_type_name, entropy)` → + /// document id. `0` selects `Document::generate_document_id_v0`. + pub derive_document_id: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v1.rs index 8911129ab66..49cb24ce2a6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v1.rs @@ -27,5 +27,6 @@ pub const DOCUMENT_VERSIONS_V1: DPPDocumentVersions = DPPDocumentVersions { get_raw_for_contract: 0, get_raw_for_document_type: 0, try_into_asset_unlock_base_transaction_info: 0, + derive_document_id: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v2.rs index 76116755df8..6f7dd240a70 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v2.rs @@ -27,5 +27,6 @@ pub const DOCUMENT_VERSIONS_V2: DPPDocumentVersions = DPPDocumentVersions { get_raw_for_contract: 0, get_raw_for_document_type: 0, try_into_asset_unlock_base_transaction_info: 0, + derive_document_id: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v3.rs index 67837019e8b..9df0c2192da 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v3.rs @@ -27,5 +27,6 @@ pub const DOCUMENT_VERSIONS_V3: DPPDocumentVersions = DPPDocumentVersions { get_raw_for_contract: 0, get_raw_for_document_type: 0, try_into_asset_unlock_base_transaction_info: 0, + derive_document_id: 0, }, }; diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index 1912d51f704..ef89fddeefd 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -34,6 +34,18 @@ pub struct DocumentCreateTransitionBuilder { pub settings: Option, pub user_fee_increase: Option, pub state_transition_creation_options: Option, + /// Tracks whether [`Self::user_fee_increase`] was last set by an + /// explicit [`Self::with_user_fee_increase`] call (as opposed to being + /// extracted from a [`Self::with_settings`] call). Used by + /// [`Self::with_settings`] to honor the "explicit setter wins + /// regardless of order" contract while still letting a second + /// `with_settings` call overwrite a prior settings-derived value. + user_fee_increase_explicit: bool, + /// Tracks whether [`Self::state_transition_creation_options`] was last + /// set by an explicit + /// [`Self::with_state_transition_creation_options`] call; mirrors + /// [`Self::user_fee_increase_explicit`] for the second dedicated field. + state_transition_creation_options_explicit: bool, } impl DocumentCreateTransitionBuilder { @@ -64,6 +76,8 @@ impl DocumentCreateTransitionBuilder { settings: None, user_fee_increase: None, state_transition_creation_options: None, + user_fee_increase_explicit: false, + state_transition_creation_options_explicit: false, } } @@ -97,6 +111,7 @@ impl DocumentCreateTransitionBuilder { /// * `Self` - The updated builder pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self { self.user_fee_increase = Some(user_fee_increase); + self.user_fee_increase_explicit = true; self } @@ -106,15 +121,23 @@ impl DocumentCreateTransitionBuilder { /// by their dedicated builder fields, which are the single source of /// truth for the effective values applied at sign time. This method /// extracts those two fields out of the supplied [`PutSettings`] into - /// the dedicated fields **only if** the dedicated field is still - /// `None`, and then stores the remainder of `settings` (with those two - /// fields cleared) on the builder. - /// - /// Net effect: explicit setters always win over `with_settings` for - /// `user_fee_increase` and `state_transition_creation_options`, regardless - /// of call order. All other [`PutSettings`] fields (timeouts, retry - /// behavior, nonce stale time, etc.) flow through unchanged to be used - /// for nonce allocation and broadcast. + /// the dedicated fields, **overwriting** any value previously derived + /// from a prior [`Self::with_settings`] call but leaving values set by + /// the explicit [`Self::with_user_fee_increase`] / + /// [`Self::with_state_transition_creation_options`] setters untouched. + /// The remainder of `settings` (with those two fields cleared) is then + /// stored on the builder. + /// + /// Net effect: + /// * a second `with_settings` call replaces the prior settings-derived + /// fee/options instead of being silently dropped; + /// * explicit setters always win over `with_settings` for + /// `user_fee_increase` and `state_transition_creation_options`, + /// regardless of call order. + /// + /// All other [`PutSettings`] fields (timeouts, retry behavior, nonce + /// stale time, etc.) flow through unchanged to be used for nonce + /// allocation and broadcast. /// /// # Arguments /// @@ -126,7 +149,9 @@ impl DocumentCreateTransitionBuilder { pub fn with_settings(mut self, settings: PutSettings) -> Self { let stored = settings.split_dedicated_fields( &mut self.user_fee_increase, + self.user_fee_increase_explicit, &mut self.state_transition_creation_options, + self.state_transition_creation_options_explicit, ); self.settings = Some(stored); self @@ -151,6 +176,7 @@ impl DocumentCreateTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + self.state_transition_creation_options_explicit = true; self } @@ -192,6 +218,7 @@ impl DocumentCreateTransitionBuilder { &self.document, document_type, &self.document_state_transition_entropy, + platform_version, )?; } @@ -250,6 +277,7 @@ impl DocumentCreateTransitionBuilder { &self.document, document_type, &self.document_state_transition_entropy, + platform_version, )?; let state_transition = BatchTransition::new_document_creation_transition_from_document( @@ -382,6 +410,52 @@ mod tests { assert_eq!(explicit_first.user_fee_increase, Some(42)); } + /// A second `with_settings` call must overwrite the prior + /// settings-derived fee/options on the dedicated builder fields. + /// Otherwise calling `with_settings` twice would silently drop the + /// second call's `user_fee_increase` / `state_transition_creation_options`. + #[test] + fn second_with_settings_overwrites_prior_settings_derived_fee_and_options() { + let data_contract = fixture_data_contract(); + let document = fixture_document(&data_contract); + + let first = PutSettings { + user_fee_increase: Some(7), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }), + ..Default::default() + }; + let second = PutSettings { + user_fee_increase: Some(42), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }), + ..Default::default() + }; + assert_ne!( + first.state_transition_creation_options, second.state_transition_creation_options, + "test precondition: first/second options must differ to prove which one wins" + ); + + let builder = DocumentCreateTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + [0u8; 32], + ) + .with_settings(first) + .with_settings(second); + + assert_eq!(builder.user_fee_increase, second.user_fee_increase); + assert_eq!( + builder.state_transition_creation_options, + second.state_transition_creation_options + ); + } + /// `with_settings` must preserve every non-fee/options `PutSettings` /// field as supplied so timeouts, retry behavior, and nonce stale time /// still drive nonce allocation and broadcast. @@ -464,6 +538,7 @@ impl Sdk { settings, user_fee_increase, state_transition_creation_options, + .. } = create_document_transition_builder; // Preserve broadcast-time settings (request_settings, wait_timeout, diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 1432223ca75..3fd008858f2 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -29,6 +29,14 @@ pub struct DocumentDeleteTransitionBuilder { pub settings: Option, pub user_fee_increase: Option, pub state_transition_creation_options: Option, + /// Tracks whether [`Self::user_fee_increase`] was last set by an + /// explicit [`Self::with_user_fee_increase`] call (as opposed to being + /// extracted from a [`Self::with_settings`] call). See the create + /// builder for the full contract. + user_fee_increase_explicit: bool, + /// Mirror of [`Self::user_fee_increase_explicit`] for the + /// `state_transition_creation_options` dedicated field. + state_transition_creation_options_explicit: bool, } impl DocumentDeleteTransitionBuilder { @@ -59,6 +67,8 @@ impl DocumentDeleteTransitionBuilder { settings: None, user_fee_increase: None, state_transition_creation_options: None, + user_fee_increase_explicit: false, + state_transition_creation_options_explicit: false, } } @@ -117,6 +127,7 @@ impl DocumentDeleteTransitionBuilder { /// * `Self` - The updated builder pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self { self.user_fee_increase = Some(user_fee_increase); + self.user_fee_increase_explicit = true; self } @@ -126,15 +137,23 @@ impl DocumentDeleteTransitionBuilder { /// by their dedicated builder fields, which are the single source of /// truth for the effective values applied at sign time. This method /// extracts those two fields out of the supplied [`PutSettings`] into - /// the dedicated fields **only if** the dedicated field is still - /// `None`, and then stores the remainder of `settings` (with those two - /// fields cleared) on the builder. - /// - /// Net effect: explicit setters always win over `with_settings` for - /// `user_fee_increase` and `state_transition_creation_options`, regardless - /// of call order. All other [`PutSettings`] fields (timeouts, retry - /// behavior, nonce stale time, etc.) flow through unchanged to be used - /// for nonce allocation and broadcast. + /// the dedicated fields, **overwriting** any value previously derived + /// from a prior [`Self::with_settings`] call but leaving values set by + /// the explicit [`Self::with_user_fee_increase`] / + /// [`Self::with_state_transition_creation_options`] setters untouched. + /// The remainder of `settings` (with those two fields cleared) is then + /// stored on the builder. + /// + /// Net effect: + /// * a second `with_settings` call replaces the prior settings-derived + /// fee/options instead of being silently dropped; + /// * explicit setters always win over `with_settings` for + /// `user_fee_increase` and `state_transition_creation_options`, + /// regardless of call order. + /// + /// All other [`PutSettings`] fields (timeouts, retry behavior, nonce + /// stale time, etc.) flow through unchanged to be used for nonce + /// allocation and broadcast. /// /// # Arguments /// @@ -146,7 +165,9 @@ impl DocumentDeleteTransitionBuilder { pub fn with_settings(mut self, settings: PutSettings) -> Self { let stored = settings.split_dedicated_fields( &mut self.user_fee_increase, + self.user_fee_increase_explicit, &mut self.state_transition_creation_options, + self.state_transition_creation_options_explicit, ); self.settings = Some(stored); self @@ -171,6 +192,7 @@ impl DocumentDeleteTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + self.state_transition_creation_options_explicit = true; self } @@ -360,6 +382,59 @@ mod tests { use dpp::tests::fixtures::get_data_contract_fixture; use dpp::version::PlatformVersion; + /// A second `with_settings` call must overwrite the prior + /// settings-derived fee/options on the dedicated builder fields. + /// Otherwise calling `with_settings` twice would silently drop the + /// second call's `user_fee_increase` / + /// `state_transition_creation_options`. + #[test] + fn second_with_settings_overwrites_prior_settings_derived_fee_and_options() { + let data_contract = Arc::new( + get_data_contract_fixture( + None, + Default::default(), + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(), + ); + + let first = PutSettings { + user_fee_increase: Some(7), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }), + ..Default::default() + }; + let second = PutSettings { + user_fee_increase: Some(42), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }), + ..Default::default() + }; + assert_ne!( + first.state_transition_creation_options, second.state_transition_creation_options, + "test precondition: first/second options must differ to prove which one wins" + ); + + let builder = DocumentDeleteTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + Identifier::default(), + Identifier::default(), + ) + .with_settings(first) + .with_settings(second); + + assert_eq!(builder.user_fee_increase, second.user_fee_increase); + assert_eq!( + builder.state_transition_creation_options, + second.state_transition_creation_options + ); + } + #[test] fn with_settings_extracts_fee_and_options_into_dedicated_fields() { let settings = PutSettings { diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index 4e385e56c55..4fcc26fab52 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -31,6 +31,14 @@ pub struct DocumentReplaceTransitionBuilder { pub settings: Option, pub user_fee_increase: Option, pub state_transition_creation_options: Option, + /// Tracks whether [`Self::user_fee_increase`] was last set by an + /// explicit [`Self::with_user_fee_increase`] call (as opposed to being + /// extracted from a [`Self::with_settings`] call). See the create + /// builder for the full contract. + user_fee_increase_explicit: bool, + /// Mirror of [`Self::user_fee_increase_explicit`] for the + /// `state_transition_creation_options` dedicated field. + state_transition_creation_options_explicit: bool, } impl DocumentReplaceTransitionBuilder { @@ -58,6 +66,8 @@ impl DocumentReplaceTransitionBuilder { settings: None, user_fee_increase: None, state_transition_creation_options: None, + user_fee_increase_explicit: false, + state_transition_creation_options_explicit: false, } } @@ -91,6 +101,7 @@ impl DocumentReplaceTransitionBuilder { /// * `Self` - The updated builder pub fn with_user_fee_increase(mut self, user_fee_increase: UserFeeIncrease) -> Self { self.user_fee_increase = Some(user_fee_increase); + self.user_fee_increase_explicit = true; self } @@ -100,15 +111,23 @@ impl DocumentReplaceTransitionBuilder { /// by their dedicated builder fields, which are the single source of /// truth for the effective values applied at sign time. This method /// extracts those two fields out of the supplied [`PutSettings`] into - /// the dedicated fields **only if** the dedicated field is still - /// `None`, and then stores the remainder of `settings` (with those two - /// fields cleared) on the builder. - /// - /// Net effect: explicit setters always win over `with_settings` for - /// `user_fee_increase` and `state_transition_creation_options`, regardless - /// of call order. All other [`PutSettings`] fields (timeouts, retry - /// behavior, nonce stale time, etc.) flow through unchanged to be used - /// for nonce allocation and broadcast. + /// the dedicated fields, **overwriting** any value previously derived + /// from a prior [`Self::with_settings`] call but leaving values set by + /// the explicit [`Self::with_user_fee_increase`] / + /// [`Self::with_state_transition_creation_options`] setters untouched. + /// The remainder of `settings` (with those two fields cleared) is then + /// stored on the builder. + /// + /// Net effect: + /// * a second `with_settings` call replaces the prior settings-derived + /// fee/options instead of being silently dropped; + /// * explicit setters always win over `with_settings` for + /// `user_fee_increase` and `state_transition_creation_options`, + /// regardless of call order. + /// + /// All other [`PutSettings`] fields (timeouts, retry behavior, nonce + /// stale time, etc.) flow through unchanged to be used for nonce + /// allocation and broadcast. /// /// # Arguments /// @@ -120,7 +139,9 @@ impl DocumentReplaceTransitionBuilder { pub fn with_settings(mut self, settings: PutSettings) -> Self { let stored = settings.split_dedicated_fields( &mut self.user_fee_increase, + self.user_fee_increase_explicit, &mut self.state_transition_creation_options, + self.state_transition_creation_options_explicit, ); self.settings = Some(stored); self @@ -145,6 +166,7 @@ impl DocumentReplaceTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + self.state_transition_creation_options_explicit = true; self } @@ -321,6 +343,49 @@ mod tests { assert_eq!(stored.state_transition_creation_options, None); } + /// A second `with_settings` call must overwrite the prior + /// settings-derived fee/options on the dedicated builder fields. + #[test] + fn second_with_settings_overwrites_prior_settings_derived_fee_and_options() { + let data_contract = fixture_data_contract(); + let document = fixture_replace_document(&data_contract); + + let first = PutSettings { + user_fee_increase: Some(7), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(7), + ..Default::default() + }), + ..Default::default() + }; + let second = PutSettings { + user_fee_increase: Some(42), + state_transition_creation_options: Some(StateTransitionCreationOptions { + batch_feature_version: Some(2), + ..Default::default() + }), + ..Default::default() + }; + assert_ne!( + first.state_transition_creation_options, second.state_transition_creation_options, + "test precondition: first/second options must differ to prove which one wins" + ); + + let builder = DocumentReplaceTransitionBuilder::new( + data_contract, + "niceDocument".to_string(), + document, + ) + .with_settings(first) + .with_settings(second); + + assert_eq!(builder.user_fee_increase, second.user_fee_increase); + assert_eq!( + builder.state_transition_creation_options, + second.state_transition_creation_options + ); + } + /// Explicit setters must beat `with_settings` regardless of call order. #[test] fn explicit_setters_beat_settings_regardless_of_order() { @@ -408,6 +473,7 @@ impl Sdk { settings, user_fee_increase, state_transition_creation_options, + .. } = replace_document_transition_builder; // Keep original settings for broadcast (request_settings, diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index 646efa07230..e62fd79459e 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -108,48 +108,78 @@ pub(crate) fn ensure_revision_for_replace(revision: Option) -> Result<(), E } } -/// Centralized v0 document-id derivation. +/// Platform-version-dispatched document-id derivation. /// /// Both the strict create-path id check and the legacy create-path entropy /// fallback go through this helper so the document-id formula has exactly -/// **one** canonical implementation in this module. +/// **one** canonical dispatch site in this module. /// -/// # v0 versioning note -/// -/// This helper is intentionally hard-pinned to -/// [`Document::generate_document_id_v0`]. A future protocol version that -/// introduces a new document-id derivation should change this helper (or -/// branch internally on platform version) — grep for `derive_document_id_v0` -/// **and** `generate_document_id_v0` to catch every call site that needs to -/// migrate together. -/// -/// **TODO (consensus / platform-version migration):** when a new -/// document-id derivation is introduced via a consensus or platform-version -/// bump, this helper and every `generate_document_id_v0` call site (the -/// strict create-path id check, the legacy entropy fallback, the wasm-sdk -/// fast-path id check, and the Rust SDK tests) must move to a -/// platform-version-aware dispatch in lockstep. The current v0-only pin -/// is a deliberate choice — the strict create path rejects mismatches with -/// `Error::InvalidArgument`, so once a v1 derivation ships, native callers -/// that have hand-derived ids with the old formula will start failing here -/// until they migrate. -fn derive_document_id_v0( +/// The active version is read from +/// `platform_version.dpp.document_versions.document_method_versions.derive_document_id`. +/// `0` selects [`Document::generate_document_id_v0`]; an unknown version +/// surfaces as [`dpp::ProtocolError::UnknownVersionMismatch`] so a new +/// derivation introduced in a future platform version is rejected fast at +/// every call site instead of silently using the v0 formula. +fn derive_document_id( document_type: DocumentTypeRef<'_>, owner_id: &Identifier, entropy: &[u8; 32], -) -> Identifier { - Document::generate_document_id_v0( + platform_version: &dpp::version::PlatformVersion, +) -> Result { + derive_document_id_from_parts( &document_type.data_contract_id(), owner_id, document_type.name(), - entropy.as_slice(), + entropy, + platform_version, ) } +/// Platform-version-dispatched document-id derivation from raw parts. +/// +/// Identical to [`derive_document_id`] but accepts the bare +/// `(contract_id, owner_id, document_type_name)` tuple instead of a +/// [`DocumentTypeRef`]. Exposed publicly so out-of-tree callers +/// (e.g. the `wasm-sdk` fast id-vs-entropy check on +/// `prepareDocumentCreate`) can dispatch through the same single +/// `DocumentMethodVersions::derive_document_id` match without +/// duplicating the version table or carrying a `DocumentType` value. +/// +/// `0` selects [`Document::generate_document_id_v0`]; an unknown +/// version surfaces as [`dpp::ProtocolError::UnknownVersionMismatch`] +/// so a new derivation introduced in a future platform version is +/// rejected fast at every call site instead of silently using the v0 +/// formula. +pub fn derive_document_id_from_parts( + contract_id: &Identifier, + owner_id: &Identifier, + document_type_name: &str, + entropy: &[u8; 32], + platform_version: &dpp::version::PlatformVersion, +) -> Result { + match platform_version + .dpp + .document_versions + .document_method_versions + .derive_document_id + { + 0 => Ok(Document::generate_document_id_v0( + contract_id, + owner_id, + document_type_name, + entropy.as_slice(), + )), + version => Err(dpp::ProtocolError::UnknownVersionMismatch { + method: "derive_document_id".to_string(), + known_versions: vec![0], + received: version, + }), + } +} + /// Strict create-path id check: documents handed to /// [`build_signed_document_create_transition`] must already have their `id` -/// derived from the supplied entropy via [`derive_document_id_v0`] (currently -/// [`Document::generate_document_id_v0`]). +/// derived from the supplied entropy via [`derive_document_id`]. /// /// This guards against silently signing a transition whose committed /// document id does not match the entropy bound into the create transition. @@ -160,12 +190,20 @@ pub(crate) fn ensure_document_id_matches_entropy( document: &Document, document_type: DocumentTypeRef<'_>, entropy: &[u8; 32], + platform_version: &dpp::version::PlatformVersion, ) -> Result<(), Error> { - let expected = derive_document_id_v0(document_type, &document.owner_id(), entropy); + let expected = derive_document_id( + document_type, + &document.owner_id(), + entropy, + platform_version, + ) + .map_err(Error::Protocol)?; if document.id() != expected { return Err(Error::InvalidArgument(format!( - "document.id does not match \ - generate_document_id_v0(contract_id, owner_id, document_type_name, entropy); \ + "document.id does not match the platform-version-dispatched \ + document-id derivation \ + (contract_id, owner_id, document_type_name, entropy); \ expected {expected}, got {got}. \ Either set document.id to the derived value before calling \ build_signed_document_create_transition, or use the legacy \ @@ -181,23 +219,28 @@ fn resolve_document_create_entropy( document: &Document, document_type: &DocumentType, document_state_transition_entropy: Option<[u8; 32]>, -) -> (Document, [u8; 32]) { - document_state_transition_entropy - .map(|entropy| (document.clone(), entropy)) - .unwrap_or_else(|| { + platform_version: &dpp::version::PlatformVersion, +) -> Result<(Document, [u8; 32]), Error> { + match document_state_transition_entropy { + Some(entropy) => Ok((document.clone(), entropy)), + None => { let mut rng = StdRng::from_entropy(); let mut doc = document.clone(); let entropy = rng.gen::<[u8; 32]>(); - // Use the centralized v0 derivation so the legacy auto-generate - // fallback always agrees with the strict id-matches-entropy - // check in `ensure_document_id_matches_entropy`. - doc.set_id(derive_document_id_v0( + // Use the centralized dispatched derivation so the legacy + // auto-generate fallback always agrees with the strict + // id-matches-entropy check in `ensure_document_id_matches_entropy`. + let id = derive_document_id( document_type.as_ref(), &doc.owner_id(), &entropy, - )); - (doc, entropy) - }) + platform_version, + ) + .map_err(Error::Protocol)?; + doc.set_id(id); + Ok((doc, entropy)) + } + } } #[async_trait::async_trait] @@ -433,7 +476,7 @@ async fn build_signed_document_create_transition_owned> PutDocument for Document { } None => { let (resolved_document, resolved_entropy) = - resolve_document_create_entropy(self, &document_type, None); + resolve_document_create_entropy(self, &document_type, None, sdk.version())?; build_signed_document_create_transition_owned( sdk, resolved_document, @@ -921,18 +965,26 @@ mod tests { assert!(initial.to_string().contains("replace requires revision")); } + /// `derive_document_id_from_parts` must produce the same id bytes + /// as the underlying `Document::generate_document_id_v0` for any + /// `(contract_id, owner_id, document_type_name, entropy)` tuple on + /// the v0 arm — otherwise the strict id-matches-entropy guard, the + /// legacy auto-generate fallback, and the wasm-sdk fast pre-check + /// could disagree silently. #[test] - fn derive_document_id_v0_matches_generate_document_id_v0() { - // The centralized helper must produce the same id bytes as the - // underlying `Document::generate_document_id_v0` for any - // (document_type, owner_id, entropy) triple — otherwise the strict - // id-matches-entropy guard and the legacy auto-generate fallback - // could disagree silently. + fn derive_document_id_from_parts_matches_generate_document_id_v0() { let document_type = test_document_type(); let owner_id = Identifier::from([0x42; 32]); let entropy = [0xCCu8; 32]; - let derived = derive_document_id_v0(document_type.as_ref(), &owner_id, &entropy); + let derived = derive_document_id_from_parts( + &document_type.data_contract_id(), + &owner_id, + document_type.name(), + &entropy, + PlatformVersion::latest(), + ) + .expect("v0 arm must succeed on latest platform version"); let direct = Document::generate_document_id_v0( &document_type.data_contract_id(), &owner_id, @@ -943,14 +995,69 @@ mod tests { assert_eq!(derived, direct); } + /// `derive_document_id` must dispatch on + /// `platform_version.dpp.document_versions.document_method_versions.derive_document_id`, + /// matching the v0 formula on the v0 arm and surfacing + /// `UnknownVersionMismatch` for any other version constant. The + /// dispatch is checked by mutating the platform-version field + /// directly so the test exercises the match arm without depending on + /// a future platform version landing. + #[test] + fn derive_document_id_dispatches_on_platform_version() { + let document_type = test_document_type(); + let owner_id = Identifier::from([0x55; 32]); + let entropy = [0xAAu8; 32]; + + let v0_id = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &owner_id, + document_type.name(), + entropy.as_slice(), + ); + + let latest = PlatformVersion::latest(); + let derived = derive_document_id(document_type.as_ref(), &owner_id, &entropy, latest) + .expect("v0 arm must succeed on latest platform version"); + assert_eq!(derived, v0_id); + + // Synthesize an unknown future version of the derivation to prove + // the dispatcher rejects it instead of silently using the v0 + // formula. + let mut bumped = latest.clone(); + bumped + .dpp + .document_versions + .document_method_versions + .derive_document_id = 99; + let err = derive_document_id(document_type.as_ref(), &owner_id, &entropy, &bumped) + .expect_err("unknown derive_document_id version must error"); + match err { + dpp::ProtocolError::UnknownVersionMismatch { + method, + known_versions, + received, + } => { + assert_eq!(method, "derive_document_id"); + assert_eq!(known_versions, vec![0]); + assert_eq!(received, 99); + } + other => panic!("expected UnknownVersionMismatch, got {other:?}"), + } + } + #[test] fn creation_entropy_fallback_regenerates_document_id() { let document_type = test_document_type(); let original_id = Identifier::from([3; 32]); let document = test_document(None, original_id); - let (resolved_document, entropy) = - resolve_document_create_entropy(&document, &document_type, None); + let (resolved_document, entropy) = resolve_document_create_entropy( + &document, + &document_type, + None, + PlatformVersion::latest(), + ) + .expect("resolve_document_create_entropy must accept latest platform version"); let expected_id = Document::generate_document_id_v0( &document_type.data_contract_id(), @@ -970,8 +1077,13 @@ mod tests { let document = test_document(Some(INITIAL_REVISION), original_id); let provided_entropy = [11; 32]; - let (resolved_document, resolved_entropy) = - resolve_document_create_entropy(&document, &document_type, Some(provided_entropy)); + let (resolved_document, resolved_entropy) = resolve_document_create_entropy( + &document, + &document_type, + Some(provided_entropy), + PlatformVersion::latest(), + ) + .expect("resolve_document_create_entropy must accept latest platform version"); assert_eq!(resolved_entropy, provided_entropy); assert_eq!(resolved_document.id(), original_id); diff --git a/packages/rs-sdk/src/platform/transition/put_settings.rs b/packages/rs-sdk/src/platform/transition/put_settings.rs index 7cc143a9412..45facc2210a 100644 --- a/packages/rs-sdk/src/platform/transition/put_settings.rs +++ b/packages/rs-sdk/src/platform/transition/put_settings.rs @@ -35,28 +35,42 @@ impl PutSettings { /// /// Used by the document `with_settings` implementations on the create, /// replace, and delete builders so each builder shares one implementation - /// of the "explicit dedicated setters always win" contract: + /// of the "explicit dedicated setters always win, with_settings + /// overrides prior settings-derived values" contract: /// - /// * if the builder's dedicated `user_fee_increase` / - /// `state_transition_creation_options` field is **already set** - /// (`Some(_)`), keep it — `with_settings` must not clobber an - /// explicit setter, regardless of call order. - /// * otherwise, move the corresponding field out of `settings` into - /// the dedicated field. + /// * if the corresponding `*_explicit` flag is `true`, the builder's + /// dedicated field was last written by an explicit setter + /// ([`with_user_fee_increase`] / + /// [`with_state_transition_creation_options`]); keep it — explicit + /// setters always win over `with_settings`, regardless of call order. + /// * otherwise, replace the corresponding dedicated field with the + /// value coming from `settings` — **including `None`**. This means + /// a second `with_settings` call **overwrites** a prior + /// settings-derived value rather than being silently dropped, and a + /// second `with_settings` with `field: None` **clears** the + /// settings-derived value the previous `with_settings` populated. + /// The contract is "last `with_settings` wins for settings-derived + /// fields, but an explicit setter always beats every + /// `with_settings`" — call order between the two stays irrelevant. /// * in both cases, the returned `PutSettings` has both fields zeroed /// so the dedicated builder fields are the sole source of truth at /// sign time. Every other [`PutSettings`] field (timeouts, retry /// behavior, nonce stale time, etc.) is preserved unchanged for /// nonce allocation and broadcast. + /// + /// [`with_user_fee_increase`]: crate::platform::documents::transitions::create::DocumentCreateTransitionBuilder::with_user_fee_increase + /// [`with_state_transition_creation_options`]: crate::platform::documents::transitions::create::DocumentCreateTransitionBuilder::with_state_transition_creation_options pub fn split_dedicated_fields( mut self, dedicated_user_fee_increase: &mut Option, + user_fee_increase_explicit: bool, dedicated_state_transition_creation_options: &mut Option, + state_transition_creation_options_explicit: bool, ) -> Self { - if dedicated_user_fee_increase.is_none() { + if !user_fee_increase_explicit { *dedicated_user_fee_increase = self.user_fee_increase; } - if dedicated_state_transition_creation_options.is_none() { + if !state_transition_creation_options_explicit { *dedicated_state_transition_creation_options = self.state_transition_creation_options; } self.user_fee_increase = None; @@ -64,3 +78,94 @@ impl PutSettings { self } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A `with_settings` call must overwrite a prior settings-derived + /// dedicated value, including down to `None`. This pins the + /// "last `with_settings` wins for settings-derived fields" semantics + /// documented on [`split_dedicated_fields`], so a second partial + /// `PutSettings` does not silently inherit values from an earlier + /// `with_settings` call. + #[test] + fn second_with_settings_with_none_clears_prior_settings_derived_value() { + let first = PutSettings { + user_fee_increase: Some(7), + ..Default::default() + }; + let mut dedicated_user_fee_increase: Option = None; + let mut dedicated_options: Option = None; + let _carryover = first.split_dedicated_fields( + &mut dedicated_user_fee_increase, + false, + &mut dedicated_options, + false, + ); + assert_eq!(dedicated_user_fee_increase, Some(7)); + + let second = PutSettings::default(); + let _carryover = second.split_dedicated_fields( + &mut dedicated_user_fee_increase, + false, + &mut dedicated_options, + false, + ); + assert_eq!( + dedicated_user_fee_increase, None, + "second with_settings with None must clear a prior settings-derived user_fee_increase" + ); + } + + /// An explicit setter (`*_explicit = true`) must beat every + /// `with_settings`, regardless of call order. A subsequent + /// `with_settings` — even with `field: None` — must not clobber + /// the explicit setter's value. + #[test] + fn explicit_setter_wins_over_subsequent_with_settings_none() { + let mut dedicated_user_fee_increase: Option = Some(42); + let mut dedicated_options: Option = None; + + let settings = PutSettings::default(); + let _carryover = settings.split_dedicated_fields( + &mut dedicated_user_fee_increase, + // explicit setter previously wrote 42 — must be preserved. + true, + &mut dedicated_options, + false, + ); + assert_eq!( + dedicated_user_fee_increase, + Some(42), + "explicit setter must win over a later with_settings, even with field: None" + ); + } + + /// `split_dedicated_fields` must leave every non-dedicated + /// `PutSettings` field (timeouts, retry behavior, nonce stale time, + /// request settings) untouched, so the remainder of `PutSettings` is + /// safe to thread through nonce allocation and broadcast. + #[test] + fn split_dedicated_fields_preserves_non_dedicated_fields() { + let settings = PutSettings { + user_fee_increase: Some(3), + identity_nonce_stale_time_s: Some(11), + wait_timeout: Some(Duration::from_secs(7)), + ..Default::default() + }; + let mut dedicated_user_fee_increase: Option = None; + let mut dedicated_options: Option = None; + let remaining = settings.split_dedicated_fields( + &mut dedicated_user_fee_increase, + false, + &mut dedicated_options, + false, + ); + + assert_eq!(remaining.user_fee_increase, None); + assert_eq!(remaining.state_transition_creation_options, None); + assert_eq!(remaining.identity_nonce_stale_time_s, Some(11)); + assert_eq!(remaining.wait_timeout, Some(Duration::from_secs(7))); + } +} diff --git a/packages/rs-sdk/src/platform/transition/validation.rs b/packages/rs-sdk/src/platform/transition/validation.rs index ef5b3a50324..b3a955cd861 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -6,8 +6,39 @@ use dpp::{ version::PlatformVersion, }; -/// Checks if an error is an UnsupportedFeatureError -fn is_unsupported_feature_error(error: &ConsensusError) -> bool { +/// Prefix that DPP's root `validate_structure` uses on the +/// `UnsupportedFeatureError::feature_name` when it returns the +/// "structure validation is not implemented for this state transition +/// kind" sentinel (see rs-dpp `state_transition/mod.rs` +/// `StateTransitionStructureValidation` impl). We match on the prefix +/// rather than the exact string so a future DPP refinement of the +/// sentinel message — e.g. broadening it past identity-based STs to +/// cover Batch as well, which already shares that arm — does not +/// silently break the pass-through. +const STRUCTURE_VALIDATION_SENTINEL_PREFIX: &str = "structure validation"; + +/// Checks if an error is the DPP "structure validation is not +/// implemented for this state-transition kind" sentinel: an +/// `UnsupportedFeatureError` whose `feature_name` starts with +/// `"structure validation"`. This is what DPP's root +/// `validate_structure` returns for identity-based STs and Batch (see +/// rs-dpp `state_transition/mod.rs`). Other `UnsupportedFeatureError` +/// instances — e.g. token-config-update-transition rejecting a +/// sub-feature it does not yet support — use different +/// `feature_name`s and are treated as real failures, never as +/// placeholders to be passed through. +fn is_structure_validation_sentinel(error: &ConsensusError) -> bool { + matches!( + error, + ConsensusError::BasicError(BasicError::UnsupportedFeatureError(e)) + if e.feature().starts_with(STRUCTURE_VALIDATION_SENTINEL_PREFIX) + ) +} + +/// Checks if an error is *any* `UnsupportedFeatureError` (including +/// non-sentinel uses that flag a specific in-ST sub-feature as +/// unsupported on this platform version). +fn is_any_unsupported_feature_error(error: &ConsensusError) -> bool { matches!( error, ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) @@ -20,46 +51,62 @@ fn is_unsupported_feature_error(error: &ConsensusError) -> bool { /// `UnsupportedFeatureError` has *two* meanings in DPP: /// /// 1. **"Structure validation is not implemented for this state transition -/// kind"** — e.g. identity-based STs return a result that is *entirely* -/// `UnsupportedFeatureError` entries. In this case we treat the result -/// as a no-op pass so the prepare APIs can sign and broadcast these STs -/// even though their structure check is a stub. +/// kind"** — DPP's root `validate_structure` returns a result whose +/// sole error is an `UnsupportedFeatureError` with `feature_name` +/// starting with `"structure validation"` (e.g. `"structure +/// validation for identity-based state transitions"`). The Batch ST +/// currently routes through the same sentinel arm. In this case we +/// treat the result as a no-op pass so the prepare APIs can sign +/// and broadcast these STs even though their structure check is a +/// stub. Platform itself still validates the transition during +/// execution. /// 2. **"A specific feature inside an otherwise-validated ST is not -/// supported on this platform version"** — in this case the result -/// mixes `UnsupportedFeatureError` entries with real validation -/// failures. Here the unsupported entries are *not* placeholders: they -/// are legitimate rejections that explain why a particular sub-feature +/// supported on this platform version"** — e.g. the +/// `token_config_update_transition` v0 structure check emits its own +/// `UnsupportedFeatureError` with a different `feature_name`. Here +/// the unsupported entries are *not* placeholders: they are +/// legitimate rejections that explain why a particular sub-feature /// is unavailable, and silently dropping them would discard /// user-visible diagnostic information. /// -/// To honor both meanings we only treat the "all errors are unsupported" -/// case as `Ok`. Once *any* non-unsupported error is present we surface -/// the result via the existing `From for -/// Error` conversion — which keeps the first error as a *typed* +/// To honor both meanings we only treat the result as `Ok` when *every* +/// error is the structure-validation sentinel. Once any non-sentinel +/// error is present (including a non-sentinel +/// `UnsupportedFeatureError` from case 2) we surface the result via the +/// existing `From for Error` +/// conversion — which keeps the first error as a *typed* /// `ConsensusError` so callers can pattern-match on it. To avoid the -/// conversion picking an `UnsupportedFeatureError` placeholder when a -/// real failure is also present, we first reorder the error list so the -/// first non-`UnsupportedFeatureError` entry is primary. +/// conversion picking an `UnsupportedFeatureError` entry when a real +/// failure is also present, we first **reorder** (stable-sort) the error +/// list so the first non-`UnsupportedFeatureError` entry is primary; +/// every error — sentinel and non-sentinel `UnsupportedFeatureError` +/// alike — is preserved in the result. fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result<(), Error> { if result.is_valid() { return Ok(()); } - if result.errors.iter().all(is_unsupported_feature_error) { + // Pass-through only when *every* error is the DPP sentinel. A + // non-sentinel `UnsupportedFeatureError` (case 2 above) is a real + // rejection and must surface as an `Err`. + if result.errors.iter().all(is_structure_validation_sentinel) { return Ok(()); } - // Mixed `UnsupportedFeatureError` + real-error case. The default + // Mixed real-error / `UnsupportedFeatureError` case. The default // `From for Error` conversion keeps - // the *first* error as a typed `ConsensusError`. Stable-partition the - // list so real failures come first, ensuring the typed error returned - // is the most actionable one and not an `UnsupportedFeatureError` - // placeholder. We deliberately use the existing `From` conversion so - // the returned `Error` preserves the typed `ConsensusError` variant - // for downstream pattern-matching, instead of being flattened into a - // `ProtocolError::Generic` string. + // the *first* error as a typed `ConsensusError`. Stable-sort so + // non-`UnsupportedFeatureError` failures come first, ensuring the + // typed error returned is the most actionable one and not an + // `UnsupportedFeatureError` entry. We deliberately use the existing + // `From` conversion so the returned `Error` preserves the typed + // `ConsensusError` variant for downstream pattern-matching, instead + // of being flattened into a `ProtocolError::Generic` string. Note + // this is a **reorder**, not a filter: every original error + // (sentinel and non-sentinel `UnsupportedFeatureError` alike) + // remains in the result. result.errors.sort_by_key(|e| { - if is_unsupported_feature_error(e) { + if is_any_unsupported_feature_error(e) { 1 } else { 0 @@ -73,16 +120,21 @@ fn map_validation_result(mut result: SimpleConsensusValidationResult) -> Result< /// `UnsupportedFeatureError` has two meanings in DPP — see /// [`map_validation_result`] for the full discussion. In short: /// -/// * an all-unsupported result is treated as `Ok` because DPP uses that -/// shape as a "structure validation is not implemented for this state -/// transition kind" sentinel (e.g. identity-based STs). The platform -/// will still perform validation during execution. -/// * a result that mixes `UnsupportedFeatureError` with real errors is -/// surfaced as an `Err` via the existing +/// * a result whose every error is the DPP structure-validation +/// sentinel (`UnsupportedFeatureError` with `feature_name` starting +/// with `"structure validation"`) is treated as `Ok` because DPP uses +/// that shape as a "structure validation is not implemented for this +/// state transition kind" placeholder (e.g. identity-based STs and +/// Batch). The platform will still perform validation during +/// execution. +/// * a result that mixes `UnsupportedFeatureError` with real errors — +/// or that contains a non-sentinel `UnsupportedFeatureError` flagging +/// a specific in-ST sub-feature as unsupported on this platform +/// version — is surfaced as an `Err` via the existing /// `From for Error` conversion, with -/// the real failures reordered first so the returned typed -/// `ConsensusError` is the actionable one (not an -/// `UnsupportedFeatureError` placeholder). +/// real failures reordered first so the returned typed +/// `ConsensusError` is the actionable one. Every original error is +/// preserved in the result; nothing is dropped. pub fn ensure_valid_state_transition_structure( state_transition: &StateTransition, platform_version: &PlatformVersion, @@ -96,9 +148,28 @@ mod tests { use dpp::consensus::basic::unsupported_feature_error::UnsupportedFeatureError; use dpp::consensus::basic::value_error::ValueError; + /// Non-sentinel `UnsupportedFeatureError` (case 2 in the + /// [`map_validation_result`] docs): the `feature_name` does not + /// match the DPP structure-validation sentinel prefix, so this + /// represents a real rejection of a specific in-ST sub-feature on + /// the current platform version and must surface as `Err`. fn unsupported_error() -> ConsensusError { ConsensusError::BasicError(BasicError::UnsupportedFeatureError( - UnsupportedFeatureError::new("feature".to_string(), 0), + UnsupportedFeatureError::new("token-config-update sub-feature X".to_string(), 0), + )) + } + + /// DPP root `validate_structure` sentinel — an + /// `UnsupportedFeatureError` whose `feature_name` starts with + /// `"structure validation"` (e.g. identity-based / Batch STs). Pass + /// through as a no-op so prepare APIs can sign and broadcast these + /// STs even though their structure check is a stub. + fn sentinel_unsupported_error() -> ConsensusError { + ConsensusError::BasicError(BasicError::UnsupportedFeatureError( + UnsupportedFeatureError::new( + "structure validation for identity-based state transitions".to_string(), + 0, + ), )) } @@ -108,17 +179,37 @@ mod tests { ))) } - /// When every error is an UnsupportedFeatureError we treat the validation - /// result as a no-op and return Ok. + /// When every error is the DPP structure-validation sentinel we + /// treat the validation result as a no-op and return Ok. This is + /// the pass-through that lets identity-based STs and Batch sign / + /// broadcast. #[test] - fn all_unsupported_errors_are_treated_as_ok() { + fn all_sentinel_unsupported_errors_are_treated_as_ok() { let result = SimpleConsensusValidationResult::new_with_errors(vec![ - unsupported_error(), - unsupported_error(), + sentinel_unsupported_error(), + sentinel_unsupported_error(), ]); assert!(map_validation_result(result).is_ok()); } + /// A non-sentinel `UnsupportedFeatureError` (e.g. an in-ST + /// sub-feature unsupported on this platform version) must surface + /// as `Err` and never as the sentinel pass-through. Silently + /// dropping it would discard a real user-visible rejection. + #[test] + fn non_sentinel_unsupported_errors_surface_as_err() { + let result = SimpleConsensusValidationResult::new_with_errors(vec![unsupported_error()]); + let err = map_validation_result(result) + .expect_err("non-sentinel UnsupportedFeatureError must not be passed through"); + match err { + Error::Protocol(dpp::ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) => {} + other => panic!("expected UnsupportedFeatureError typed variant, got: {other:?}"), + }, + other => panic!("expected typed ConsensusError, got: {other:?}"), + } + } + /// A single non-UnsupportedFeature error is surfaced as a real failure. #[test] fn single_real_error_is_surfaced() { @@ -159,6 +250,33 @@ mod tests { } } + /// A result that mixes the DPP structure-validation sentinel with + /// a real failure must surface as `Err` — the sentinel pass-through + /// only applies when *every* error is the sentinel. The reordering + /// also ensures the typed `ConsensusError` returned is the real + /// failure, not the sentinel placeholder. + #[test] + fn sentinel_plus_real_error_is_surfaced_as_err() { + let result = SimpleConsensusValidationResult::new_with_errors(vec![ + sentinel_unsupported_error(), + value_error("real failure"), + ]); + let err = map_validation_result(result) + .expect_err("sentinel + real failure must not be passed through"); + match err { + Error::Protocol(dpp::ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ValueError(ref ve)) => { + assert!( + ve.to_string().contains("real failure"), + "expected real-failure primary typed error, got: {ve}" + ); + } + other => panic!("expected ValueError typed variant, got: {other:?}"), + }, + other => panic!("expected typed ConsensusError, got: {other:?}"), + } + } + /// Real errors are reordered to the front even when they appear after /// `UnsupportedFeatureError` entries in the input list, so the typed /// `ConsensusError` returned by the `From` conversion is always the diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 2e6b19e9089..76760759a84 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -117,7 +117,8 @@ use dash_sdk::platform::documents::transitions::{ }; use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::{ - build_signed_document_create_transition, build_signed_document_replace_transition, PutDocument, + build_signed_document_create_transition, build_signed_document_replace_transition, + derive_document_id_from_parts, PutDocument, }; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; @@ -133,7 +134,6 @@ use wasm_dpp2::state_transitions::batch::token_payment_info::{ use wasm_dpp2::utils::{ get_class_type, try_from_options_optional, try_from_options_optional_with, try_from_options_with, try_to_fixed_bytes, try_to_string, try_to_u32, try_to_u64, JsValueExt, - ToSerdeJSONExt, }; use wasm_dpp2::IdentitySignerWasm; use wasm_dpp2::StateTransitionWasm; @@ -406,6 +406,7 @@ impl WasmSdk { document.owner_id(), &document_type_name, &entropy_array, + self.inner_sdk().version(), )?; // Extract identity key from options @@ -814,6 +815,7 @@ impl WasmSdk { document.owner_id(), &document_type_name, &entropy_array, + self.inner_sdk().version(), )?; // Extract identity key from options @@ -1659,8 +1661,7 @@ fn extract_delete_identifiers( } /// Extract a `DocumentWasm` from the `document` field of a prepare-options -/// object via *public/structural JS reads only*, building a fresh `Document` -/// in Rust from those reads. +/// object. /// /// `prepareDocumentCreate` / `prepareDocumentReplace` accept either: /// * a `wasm-dpp2` `Document` class instance (whose getters return @@ -1668,16 +1669,26 @@ fn extract_delete_identifiers( /// * a plain `{ id, ownerId, dataContractId, documentTypeName, properties, /// revision?, entropy?, createdAt?, … }` options bag. /// -/// We deliberately do **not** call `DocumentWasm::try_from` / -/// `to_wasm::("Document")` here: those paths read the -/// wasm-bindgen `__wbg_ptr` field on the value and dereference it as a Rust -/// pointer. A forged JS object can advertise `__type === "Document"` but -/// still hand us an arbitrary numeric `__wbg_ptr`, so trusting that pointer -/// on untrusted inputs is unsound. Real `Document` instances expose every -/// field below via ordinary `#[wasm_bindgen(getter = ...)]` accessors, so -/// `Reflect::get` works the same way for both shapes — but the bytes we -/// build the `Document` from are always derived from the public surface, -/// never from a caller-supplied pointer. +/// # Structural-only extraction +/// +/// Both shapes are read through the **public JS surface only** +/// (`Reflect::get`, public getters, `toBytes()` / `toString()`, +/// [`wasm_dpp2::serialization::js_value_to_platform_value`]); the +/// wasm-bindgen `__wbg_ptr` field is never dereferenced. This keeps the +/// hardening from spoofed `__type` + `__wbg_ptr` attacks consistent +/// across the two accepted input shapes, including the typed +/// `properties` payload: `js_value_to_platform_value` preserves +/// `Uint8Array` (32-byte → `Value::Identifier`, otherwise +/// `Value::Bytes`) and `BigInt` (→ `Value::U64` / `Value::I64`) coming +/// back from the public `Document.properties` getter, which itself uses +/// [`wasm_dpp2::serialization::platform_value_to_object`] to serialize +/// the inner Rust map. We deliberately do **not** call +/// `DocumentWasm::try_from(&JsValue)` / +/// `to_wasm::("Document")` here: those paths trust the +/// caller-supplied `__wbg_ptr` numeric value as a Rust pointer, which is +/// unsound on public JS input — the same rationale that motivates +/// `extract_identifier_property` / `extract_delete_identifiers` and +/// shares the threat model documented at the top of this file. fn extract_prepare_document(options: &JsValue) -> Result { let document_js = Reflect::get(options, &JsValue::from_str("document")) .map_err(|_| WasmSdkError::invalid_argument("document is required"))?; @@ -1692,8 +1703,16 @@ fn extract_prepare_document(options: &JsValue) -> Result` from the public + // `Document.properties` getter (or a plain JS options bag) without + // touching `__wbg_ptr`. `js_value_to_platform_value` preserves + // `Uint8Array` as `Value::Identifier` (32-byte) / `Value::Bytes` and + // `BigInt` as `Value::U64` / `Value::I64`, matching what + // `platform_value_to_object` produces on the getter side. let properties = try_from_options_with(&document_js, "properties", |v| { - v.with_serde_to_platform_value_map() + let pv = wasm_dpp2::serialization::js_value_to_platform_value(v)?; + pv.into_btree_string_map() + .map_err(|err| wasm_dpp2::error::WasmDppError::invalid_argument(err.to_string())) })?; let revision = @@ -1767,8 +1786,9 @@ fn extract_prepare_document(options: &JsValue) -> Result Result Result<(), WasmSdkError> { - let expected = Document::generate_document_id_v0( + let expected = derive_document_id_from_parts( &contract_id, &owner_id, document_type_name, - entropy.as_slice(), - ); + entropy, + platform_version, + ) + .map_err(|err| { + WasmSdkError::invalid_argument(format!( + "{err}; the wasm-sdk fast id-vs-entropy check could not dispatch \ + the derive_document_id version for this platform version. \ + Upgrade wasm-sdk to a build that supports this platform version." + )) + })?; if document_id != expected { return Err(WasmSdkError::invalid_argument(format!( - "document.id does not match \ - generate_document_id_v0(dataContractId, ownerId, documentTypeName, entropy); \ + "document.id does not match the platform-version-dispatched \ + document-id derivation \ + (dataContractId, ownerId, documentTypeName, entropy); \ expected {expected}, got {document_id}. \ The Document constructor derives both together by default; if you set the \ id or entropy explicitly, keep them consistent." @@ -1974,6 +2011,7 @@ mod tests { owner_id, document_type_name, &entropy, + PlatformVersion::latest(), ) .is_ok()); @@ -1985,6 +2023,7 @@ mod tests { owner_id, document_type_name, &entropy, + PlatformVersion::latest(), ) .expect_err("mismatch must be rejected"); let msg = err.to_string(); @@ -1994,6 +2033,45 @@ mod tests { ); } + /// Unknown `derive_document_id` versions must be rejected by the wasm + /// fast check instead of silently falling back to the v0 formula, so + /// the wasm fast pre-check tracks the same dispatch table as the + /// rs-sdk `derive_document_id_from_parts` helper. Synthesizes a + /// bumped version constant rather than depending on a future + /// platform version landing. + #[test] + fn fast_id_matches_entropy_rejects_unknown_derive_version() { + let contract_id = Identifier::from([1u8; 32]); + let owner_id = Identifier::from([2u8; 32]); + let entropy = [3u8; 32]; + let document_type_name = "note"; + // Any document id will do — the dispatcher must error before + // any equality check happens. + let document_id = Identifier::from([0xAB; 32]); + + let mut bumped = PlatformVersion::latest().clone(); + bumped + .dpp + .document_versions + .document_method_versions + .derive_document_id = 99; + + let err = ensure_document_id_matches_entropy_fast( + document_id, + contract_id, + owner_id, + document_type_name, + &entropy, + &bumped, + ) + .expect_err("unknown derive_document_id version must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("derive_document_id"), + "expected dispatch-error message mentioning derive_document_id, got: {msg}" + ); + } + /// Regression test for the UnsupportedFeatureError pass-through path. /// /// DPP's `validate_structure` implementation returns `UnsupportedFeatureError` From 0daa6c5992fc20dc75fe964b93bdb2f33460d98f Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Thu, 14 May 2026 04:04:54 -0500 Subject: [PATCH 23/24] fix(sdk): address document prepare review follow-up --- packages/rs-sdk-ffi/src/document/delete.rs | 8 +- packages/rs-sdk-ffi/src/document/helpers.rs | 80 +++++++++++++++++++ packages/rs-sdk-ffi/src/document/price.rs | 10 +-- packages/rs-sdk-ffi/src/document/purchase.rs | 10 +-- packages/rs-sdk-ffi/src/document/put.rs | 61 ++++++++++---- packages/rs-sdk-ffi/src/document/replace.rs | 6 +- packages/rs-sdk-ffi/src/document/transfer.rs | 10 +-- .../src/platform/transition/put_document.rs | 48 ++++++++--- .../src/state_transitions/document.rs | 35 +++++++- 9 files changed, 212 insertions(+), 56 deletions(-) diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index 4f2ee97b1a3..c449a5a546f 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -13,7 +13,7 @@ use std::os::raw::c_char; use tracing::{debug, error, info}; use crate::document::helpers::{ - convert_state_transition_creation_options, convert_token_payment_info, + convert_state_transition_creation_options, convert_token_payment_info, map_document_sdk_error, }; use crate::sdk::SDKWrapper; use crate::types::{ @@ -168,9 +168,7 @@ pub unsafe extern "C" fn dash_sdk_document_delete( signer, ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create delete transition: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to create delete transition"))?; // Serialize the state transition with bincode. // @@ -371,7 +369,7 @@ pub unsafe extern "C" fn dash_sdk_document_delete_and_wait( .await .map_err(|e| { error!(error = %e, key_id = identity_public_key.id(), "[DOCUMENT DELETE] SDK call failed"); - FFIError::InternalError(format!("Failed to delete document and wait: {}", e)) + map_document_sdk_error(e, "Failed to delete document and wait") })?; info!("[DOCUMENT DELETE] SDK call completed successfully"); diff --git a/packages/rs-sdk-ffi/src/document/helpers.rs b/packages/rs-sdk-ffi/src/document/helpers.rs index d38b9b15870..80aeba337c3 100644 --- a/packages/rs-sdk-ffi/src/document/helpers.rs +++ b/packages/rs-sdk-ffi/src/document/helpers.rs @@ -12,6 +12,26 @@ use crate::types::{ }; use crate::FFIError; +/// Map a `dash_sdk::Error` produced by a document state-transition +/// builder (build / sign / SDK broadcast helper) into an `FFIError`, +/// preserving caller-supplied context for non-`InvalidArgument` variants +/// while letting `InvalidArgument` flow through the typed +/// `FFIError::SDKError` → `DashSDKErrorCode::InvalidParameter` branch +/// in `error.rs`. +/// +/// Without this routing, a typed `Error::InvalidArgument` from the new +/// strict create/replace guards in rs-sdk would be wrapped as +/// `FFIError::InternalError(format!("{context}: {}", e))` and surface as +/// `DashSDKErrorCode::InternalError`, hiding the precise classification +/// from FFI callers. +pub(crate) fn map_document_sdk_error(e: dash_sdk::Error, context: &str) -> FFIError { + if matches!(e, dash_sdk::Error::InvalidArgument(_)) { + FFIError::SDKError(e) + } else { + FFIError::InternalError(format!("{}: {}", context, e)) + } +} + /// Convert FFI GasFeesPaidBy to Rust enum /// /// # Safety @@ -105,3 +125,63 @@ pub unsafe fn convert_state_transition_creation_options( }, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::DashSDKErrorCode; + + /// A typed `dash_sdk::Error::InvalidArgument` from the document + /// builder/sign paths must flow through `FFIError::SDKError` (not + /// `InternalError`) so the `From for DashSDKError` typed + /// dispatch in `error.rs` can map it to + /// `DashSDKErrorCode::InvalidParameter`. Without this routing, the + /// new strict create/replace guards in rs-sdk would surface as + /// `InternalError` to FFI callers. + #[test] + fn map_document_sdk_error_routes_invalid_argument_to_invalid_parameter() { + let sdk_err = dash_sdk::Error::InvalidArgument("entropy mismatch".to_string()); + let ffi_err = map_document_sdk_error(sdk_err, "Failed to create document transition"); + assert!( + matches!( + ffi_err, + FFIError::SDKError(dash_sdk::Error::InvalidArgument(_)) + ), + "InvalidArgument must pass through as FFIError::SDKError, got: {ffi_err:?}" + ); + + // End-to-end through the public `From for DashSDKError` + // conversion: the user-facing error code must be InvalidParameter, + // not InternalError. + let api_err: crate::DashSDKError = ffi_err.into(); + assert_eq!(api_err.code, DashSDKErrorCode::InvalidParameter); + } + + /// Non-`InvalidArgument` `dash_sdk::Error` variants must keep the + /// caller-supplied context prefix (e.g. "Failed to create document + /// transition") so existing FFI error messages are not regressed by + /// the typed pass-through. + #[test] + fn map_document_sdk_error_preserves_context_for_non_invalid_argument() { + // Use a `Protocol` variant — anything that is not + // `InvalidArgument`. The exact variant does not matter; what + // matters is that the context prefix is retained. + let sdk_err = dash_sdk::Error::Generic("boom".to_string()); + let ffi_err = map_document_sdk_error(sdk_err, "Failed to create document transition"); + match ffi_err { + FFIError::InternalError(msg) => { + assert!( + msg.starts_with("Failed to create document transition:"), + "expected context prefix, got: {msg}" + ); + assert!( + msg.contains("boom"), + "expected underlying error in msg, got: {msg}" + ); + } + other => { + panic!("expected FFIError::InternalError for non-InvalidArgument, got: {other:?}") + } + } + } +} diff --git a/packages/rs-sdk-ffi/src/document/price.rs b/packages/rs-sdk-ffi/src/document/price.rs index 18aea1485c7..8ffbe39d9d7 100644 --- a/packages/rs-sdk-ffi/src/document/price.rs +++ b/packages/rs-sdk-ffi/src/document/price.rs @@ -1,7 +1,7 @@ //! Document price update operations use crate::document::helpers::{ - convert_state_transition_creation_options, convert_token_payment_info, + convert_state_transition_creation_options, convert_token_payment_info, map_document_sdk_error, }; use crate::sdk::SDKWrapper; use crate::types::{ @@ -147,9 +147,7 @@ pub unsafe extern "C" fn dash_sdk_document_update_price_of_document( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create set price transition: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to create set price transition"))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -285,9 +283,7 @@ pub unsafe extern "C" fn dash_sdk_document_update_price_of_document_and_wait( .sdk .document_set_price(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to update document price and wait: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to update document price and wait"))?; let dash_sdk::platform::documents::transitions::DocumentSetPriceResult::Document( updated_document, diff --git a/packages/rs-sdk-ffi/src/document/purchase.rs b/packages/rs-sdk-ffi/src/document/purchase.rs index bf55bb6fc11..ae6c29ba138 100644 --- a/packages/rs-sdk-ffi/src/document/purchase.rs +++ b/packages/rs-sdk-ffi/src/document/purchase.rs @@ -1,7 +1,7 @@ //! Document purchasing operations use crate::document::helpers::{ - convert_state_transition_creation_options, convert_token_payment_info, + convert_state_transition_creation_options, convert_token_payment_info, map_document_sdk_error, }; use crate::sdk::SDKWrapper; use crate::types::{ @@ -164,9 +164,7 @@ pub unsafe extern "C" fn dash_sdk_document_purchase( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create purchase transition: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to create purchase transition"))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -332,9 +330,7 @@ pub unsafe extern "C" fn dash_sdk_document_purchase_and_wait( .sdk .document_purchase(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to purchase document and wait: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to purchase document and wait"))?; let dash_sdk::platform::documents::transitions::DocumentPurchaseResult::Document( purchased_document, diff --git a/packages/rs-sdk-ffi/src/document/put.rs b/packages/rs-sdk-ffi/src/document/put.rs index fb2539df325..ff503cb57de 100644 --- a/packages/rs-sdk-ffi/src/document/put.rs +++ b/packages/rs-sdk-ffi/src/document/put.rs @@ -12,8 +12,27 @@ use std::ffi::CStr; use std::os::raw::c_char; use crate::document::helpers::{ - convert_state_transition_creation_options, convert_token_payment_info, + convert_state_transition_creation_options, convert_token_payment_info, map_document_sdk_error, }; + +/// Whether the FFI document put paths should route to the create builder +/// for a given document revision. +/// +/// Returns `true` for `None` (legacy create intent, supported by the +/// strict rs-sdk create helper) and `Some(1)` (the +/// [`dash_sdk::dpp::document::INITIAL_REVISION`] sentinel for a freshly +/// minted document). Any other value routes to the replace builder. +/// +/// Both the no-wait (`dash_sdk_document_put_to_platform`) and wait +/// (`dash_sdk_document_put_to_platform_and_wait`) entry points share this +/// helper so they cannot drift on revision routing — historically the +/// no-wait path used `unwrap_or(0) == 1` and the wait path used +/// `unwrap_or(1) == 1`, which disagreed on the `None` case and silently +/// routed `None`-revision documents into the replace builder on the +/// no-wait path only. +fn is_document_create_revision(revision: Option) -> bool { + matches!(revision, None | Some(1)) +} use crate::sdk::SDKWrapper; use crate::types::{ DashSDKPutSettings, DashSDKResultDataType, DashSDKStateTransitionCreationOptions, @@ -112,8 +131,11 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform( (*put_settings).user_fee_increase }; - // Use the new DocumentCreateTransitionBuilder or DocumentReplaceTransitionBuilder - let state_transition = if document.revision().unwrap_or(0) == 1 { + // Route via the shared `is_document_create_revision` helper so the + // no-wait and wait paths cannot drift on revision routing. The + // helper treats `revision = None` as create — the legacy create + // intent, which the strict rs-sdk create helper also accepts. + let state_transition = if is_document_create_revision(document.revision()) { // Create transition for new documents let mut builder = DocumentCreateTransitionBuilder::new( data_contract.clone(), @@ -179,9 +201,7 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform( ) .await } - .map_err(|e| { - FFIError::InternalError(format!("Failed to create document transition: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to create document transition"))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -284,8 +304,10 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform_and_wait( (*put_settings).user_fee_increase }; - // Use the new builder pattern and SDK methods - let confirmed_document = if document.revision().unwrap_or(1) == 1 { + // Use the new builder pattern and SDK methods. Routes through the + // shared `is_document_create_revision` helper so this wait path + // agrees with the no-wait path above on revision = None routing. + let confirmed_document = if is_document_create_revision(document.revision()) { // Create transition for new documents let mut builder = DocumentCreateTransitionBuilder::new( data_contract.clone(), @@ -314,9 +336,7 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform_and_wait( .sdk .document_create(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create document and wait: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to create document and wait"))?; match result { dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { @@ -351,9 +371,7 @@ pub unsafe extern "C" fn dash_sdk_document_put_to_platform_and_wait( .sdk .document_replace(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to replace document and wait: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to replace document and wait"))?; match result { dash_sdk::platform::documents::transitions::DocumentReplaceResult::Document( @@ -424,6 +442,21 @@ mod tests { [42u8; 32] } + /// `is_document_create_revision` must agree with the routing rules + /// the FFI put paths used historically: `None` and `Some(1)` are the + /// create branch; any other value (including `Some(0)`) is the + /// replace branch. Both the no-wait and wait paths route through + /// this helper, so this test pins the shared decision so the + /// previous `unwrap_or(0)` / `unwrap_or(1)` drift cannot reappear. + #[test] + fn is_document_create_revision_matches_initial_revision_and_none() { + assert!(super::is_document_create_revision(None)); + assert!(super::is_document_create_revision(Some(1))); + assert!(!super::is_document_create_revision(Some(0))); + assert!(!super::is_document_create_revision(Some(2))); + assert!(!super::is_document_create_revision(Some(u64::MAX))); + } + #[test] fn test_put_with_null_sdk_handle() { let document = create_mock_document_with_revision(1); diff --git a/packages/rs-sdk-ffi/src/document/replace.rs b/packages/rs-sdk-ffi/src/document/replace.rs index c552bd291b2..67756ed8fd4 100644 --- a/packages/rs-sdk-ffi/src/document/replace.rs +++ b/packages/rs-sdk-ffi/src/document/replace.rs @@ -1,7 +1,7 @@ //! Document replacement operations use crate::document::helpers::{ - convert_state_transition_creation_options, convert_token_payment_info, + convert_state_transition_creation_options, convert_token_payment_info, map_document_sdk_error, }; use crate::sdk::SDKWrapper; use crate::types::{ @@ -149,7 +149,7 @@ pub unsafe extern "C" fn dash_sdk_document_replace_on_platform( .await .map_err(|e| { error!(error = %e, key_id = identity_public_key.id(), "[DOCUMENT REPLACE] failed to sign transition"); - FFIError::InternalError(format!("Failed to create replace transition: {}", e)) + map_document_sdk_error(e, "Failed to create replace transition") })?; debug!("[DOCUMENT REPLACE] state transition created, serializing"); @@ -357,7 +357,7 @@ pub unsafe extern "C" fn dash_sdk_document_replace_on_platform_and_wait( "❌ [DOCUMENT REPLACE] Failed with key ID: {}", identity_public_key.id() ); - FFIError::InternalError(format!("Failed to replace document and wait: {}", e)) + map_document_sdk_error(e, "Failed to replace document and wait") })?; eprintln!("✅ [DOCUMENT REPLACE] SDK call completed successfully"); diff --git a/packages/rs-sdk-ffi/src/document/transfer.rs b/packages/rs-sdk-ffi/src/document/transfer.rs index 9dfeeaab883..a5edd73d9a9 100644 --- a/packages/rs-sdk-ffi/src/document/transfer.rs +++ b/packages/rs-sdk-ffi/src/document/transfer.rs @@ -11,7 +11,7 @@ use std::ffi::CStr; use std::os::raw::c_char; use crate::document::helpers::{ - convert_state_transition_creation_options, convert_token_payment_info, + convert_state_transition_creation_options, convert_token_payment_info, map_document_sdk_error, }; use crate::sdk::SDKWrapper; use crate::types::{ @@ -175,9 +175,7 @@ pub unsafe extern "C" fn dash_sdk_document_transfer_to_identity( wrapper.sdk.version(), ) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create transfer transition: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to create transfer transition"))?; // Serialize the state transition with bincode let config = bincode::config::standard(); @@ -342,9 +340,7 @@ pub unsafe extern "C" fn dash_sdk_document_transfer_to_identity_and_wait( .sdk .document_transfer(builder, identity_public_key, signer) .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to transfer document and wait: {}", e)) - })?; + .map_err(|e| map_document_sdk_error(e, "Failed to transfer document and wait"))?; let dash_sdk::platform::documents::transitions::DocumentTransferResult::Document( transferred_document, diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index e62fd79459e..b4337e77b23 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -23,13 +23,29 @@ //! builders so the supplied document id and entropy commit to the same //! value. //! -//! Additionally, [`PutDocument::put_to_platform`]'s create path now -//! locally rejects when `document_state_transition_entropy = Some(entropy)` -//! and the derived id does not match `document.id` — the strict -//! [`build_signed_document_create_transition`] helper that backs it -//! enforces this id-matches-entropy check before any nonce allocation. -//! Passing `None` still preserves the legacy auto-generate-entropy / -//! rewrite-id behavior for callers that opt into it. +//! # Behavior changes in this release (semver-significant) +//! +//! The legacy public [`PutDocument::put_to_platform`] trait method now +//! performs two additional **local** validations on the create path, both +//! of which run **before any identity-contract nonce is allocated** so a +//! caller mistake cannot advance the local nonce cache past a nonce the +//! network never observed: +//! +//! 1. **`Some(0)` revisions are rejected.** Revision `0` was never valid +//! on either the create or replace path (create requires unset / +//! [`INITIAL_REVISION`]; replace requires strictly greater than +//! [`INITIAL_REVISION`]), but the previous implementation silently +//! fell through to the create path. It now surfaces as +//! [`Error::InvalidArgument`]. +//! 2. **`Some(entropy)` is checked against `document.id`.** When the +//! caller supplies entropy on the create path the trait now locally +//! rejects (via the strict [`build_signed_document_create_transition`] +//! helper that backs it) if the supplied entropy does not derive +//! `document.id` via [`Document::generate_document_id_v0`]. +//! +//! `document_state_transition_entropy = None` still preserves the legacy +//! auto-generate-entropy / rewrite-id behavior for in-tree callers +//! (e.g. `rs-platform-wallet` profile creation) that opt into it. //! //! [`build_signed_document_create_or_replace_transition`] remains public //! for source compatibility with downstream native callers that depended @@ -77,7 +93,14 @@ fn ensure_revision_nonzero(revision: Option) -> Result<(), Error> { /// Accepts `None` and `Some(INITIAL_REVISION)`. Rejects `Some(0)` and any /// revision strictly greater than `INITIAL_REVISION`. This is the rs-sdk-side /// fail-fast equivalent of the wasm-sdk `ensureDocumentCreateRevision` guard. -pub(crate) fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { +/// +/// Exposed publicly so out-of-tree callers (notably the wasm-sdk +/// `prepareDocumentCreate` / `documentCreate` revision guards) can +/// delegate acceptance to this single source of truth instead of +/// re-implementing the matching rules. Wasm callers still own their own +/// error messaging (API-name guidance, dedicated revision-0 wording) and +/// only consult this function for the accept/reject decision. +pub fn ensure_revision_for_create(revision: Option) -> Result<(), Error> { match revision { None => Ok(()), Some(rev) if rev == INITIAL_REVISION => Ok(()), @@ -93,7 +116,14 @@ pub(crate) fn ensure_revision_for_create(revision: Option) -> Result<(), Er /// Accepts only `Some(rev)` with `rev > INITIAL_REVISION`. Rejects `None`, /// `Some(0)`, and `Some(INITIAL_REVISION)`. This is the rs-sdk-side fail-fast /// equivalent of the wasm-sdk `ensureDocumentReplaceRevision` guard. -pub(crate) fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { +/// +/// Exposed publicly so out-of-tree callers (notably the wasm-sdk +/// `prepareDocumentReplace` / `documentReplace` revision guards) can +/// delegate acceptance to this single source of truth instead of +/// re-implementing the matching rules. Wasm callers still own their own +/// error messaging (API-name guidance, dedicated revision-0 wording) and +/// only consult this function for the accept/reject decision. +pub fn ensure_revision_for_replace(revision: Option) -> Result<(), Error> { match revision { Some(rev) if rev > INITIAL_REVISION => Ok(()), Some(rev) => Err(Error::InvalidArgument(format!( diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 76760759a84..c960054f443 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -118,7 +118,8 @@ use dash_sdk::platform::documents::transitions::{ use dash_sdk::platform::transition::purchase_document::PurchaseDocument; use dash_sdk::platform::transition::put_document::{ build_signed_document_create_transition, build_signed_document_replace_transition, - derive_document_id_from_parts, PutDocument, + derive_document_id_from_parts, ensure_revision_for_create, ensure_revision_for_replace, + PutDocument, }; use dash_sdk::platform::transition::transfer_document::TransferDocument; use dash_sdk::platform::transition::update_price_of_document::UpdatePriceOfDocument; @@ -1838,13 +1839,23 @@ fn ensure_document_id_matches_entropy_fast( Ok(()) } +/// Wasm-side revision guard for the document **create** path. +/// +/// Delegates the accept/reject decision to the rs-sdk +/// [`ensure_revision_for_create`] helper so wasm-sdk and rs-sdk cannot +/// drift on which revisions are valid on the create path. On rejection, +/// this function constructs wasm-specific error messages — a dedicated +/// revision-0 wording (since `Some(0)` is invalid for *both* create and +/// replace, pointing at the sibling API would mislead) and an API-name +/// hint that names the wasm-sdk replace entry point. fn ensure_document_create_revision( revision: Option, replace_api_name: &str, ) -> Result<(), WasmSdkError> { + if ensure_revision_for_create(revision).is_ok() { + return Ok(()); + } match revision { - None => Ok(()), - Some(rev) if rev == INITIAL_REVISION => Ok(()), // `Some(0)` is invalid for *both* create and replace, so do not // point users at the sibling API — they would just see the same // rejection from `ensure_document_replace_revision`. Emit a @@ -1858,15 +1869,31 @@ fn ensure_document_create_revision( "Document revision is {} but create requires revision to be unset or {}. Use {} for existing documents.", rev, INITIAL_REVISION, replace_api_name, ))), + // `ensure_revision_for_create` accepts `None`, so the rs-sdk + // helper would have short-circuited above for this case. + None => unreachable!( + "ensure_revision_for_create accepts None; wasm guard reached an unreachable arm" + ), } } +/// Wasm-side revision guard for the document **replace** path. +/// +/// Delegates the accept/reject decision to the rs-sdk +/// [`ensure_revision_for_replace`] helper so wasm-sdk and rs-sdk cannot +/// drift on which revisions are valid on the replace path. On rejection, +/// this function constructs wasm-specific error messages — a dedicated +/// revision-0 wording (since `Some(0)` is invalid for *both* create and +/// replace, pointing at the sibling API would mislead) and an API-name +/// hint that names the wasm-sdk create entry point. fn ensure_document_replace_revision( revision: Option, create_api_name: &str, ) -> Result<(), WasmSdkError> { + if ensure_revision_for_replace(revision).is_ok() { + return Ok(()); + } match revision { - Some(rev) if rev > INITIAL_REVISION => Ok(()), // `Some(0)` is invalid for *both* create and replace, so do not // point users at the sibling API — they would just see the same // rejection from `ensure_document_create_revision`. Emit a From 0d84e9b62b35b320fb4840ecd1f1076151b9739e Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Thu, 14 May 2026 06:09:26 -0500 Subject: [PATCH 24/24] fix(sdk): harden document transition builder inputs --- .../platform/documents/transitions/create.rs | 21 +++- .../platform/documents/transitions/delete.rs | 21 +++- .../platform/documents/transitions/replace.rs | 21 +++- .../src/state_transitions/document.rs | 12 +-- .../tests/unit/prepare-document.spec.ts | 101 ++++++++++++------ 5 files changed, 127 insertions(+), 49 deletions(-) diff --git a/packages/rs-sdk/src/platform/documents/transitions/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index ef89fddeefd..3fde3b8a42b 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -31,9 +31,9 @@ pub struct DocumentCreateTransitionBuilder { pub document: Document, pub document_state_transition_entropy: [u8; 32], pub token_payment_info: Option, - pub settings: Option, - pub user_fee_increase: Option, - pub state_transition_creation_options: Option, + settings: Option, + user_fee_increase: Option, + state_transition_creation_options: Option, /// Tracks whether [`Self::user_fee_increase`] was last set by an /// explicit [`Self::with_user_fee_increase`] call (as opposed to being /// extracted from a [`Self::with_settings`] call). Used by @@ -95,6 +95,21 @@ impl DocumentCreateTransitionBuilder { self } + /// Returns the stored non-dedicated put settings, if any. + pub fn settings(&self) -> Option { + self.settings + } + + /// Returns the effective dedicated user fee increase, if any. + pub fn user_fee_increase(&self) -> Option { + self.user_fee_increase + } + + /// Returns the effective dedicated creation options, if any. + pub fn state_transition_creation_options(&self) -> Option { + self.state_transition_creation_options + } + /// Adds a user fee increase to the document create transition. /// /// The dedicated [`Self::user_fee_increase`] field is the single source diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 3fd008858f2..0ce097bd229 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -26,9 +26,9 @@ pub struct DocumentDeleteTransitionBuilder { pub document_id: Identifier, pub owner_id: Identifier, pub token_payment_info: Option, - pub settings: Option, - pub user_fee_increase: Option, - pub state_transition_creation_options: Option, + settings: Option, + user_fee_increase: Option, + state_transition_creation_options: Option, /// Tracks whether [`Self::user_fee_increase`] was last set by an /// explicit [`Self::with_user_fee_increase`] call (as opposed to being /// extracted from a [`Self::with_settings`] call). See the create @@ -111,6 +111,21 @@ impl DocumentDeleteTransitionBuilder { self } + /// Returns the stored non-dedicated put settings, if any. + pub fn settings(&self) -> Option { + self.settings + } + + /// Returns the effective dedicated user fee increase, if any. + pub fn user_fee_increase(&self) -> Option { + self.user_fee_increase + } + + /// Returns the effective dedicated creation options, if any. + pub fn state_transition_creation_options(&self) -> Option { + self.state_transition_creation_options + } + /// Adds a user fee increase to the document delete transition. /// /// The dedicated [`Self::user_fee_increase`] field is the single source diff --git a/packages/rs-sdk/src/platform/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index 4fcc26fab52..1fc4068874c 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -28,9 +28,9 @@ pub struct DocumentReplaceTransitionBuilder { pub document_type_name: String, pub document: Document, pub token_payment_info: Option, - pub settings: Option, - pub user_fee_increase: Option, - pub state_transition_creation_options: Option, + settings: Option, + user_fee_increase: Option, + state_transition_creation_options: Option, /// Tracks whether [`Self::user_fee_increase`] was last set by an /// explicit [`Self::with_user_fee_increase`] call (as opposed to being /// extracted from a [`Self::with_settings`] call). See the create @@ -85,6 +85,21 @@ impl DocumentReplaceTransitionBuilder { self } + /// Returns the stored non-dedicated put settings, if any. + pub fn settings(&self) -> Option { + self.settings + } + + /// Returns the effective dedicated user fee increase, if any. + pub fn user_fee_increase(&self) -> Option { + self.user_fee_increase + } + + /// Returns the effective dedicated creation options, if any. + pub fn state_transition_creation_options(&self) -> Option { + self.state_transition_creation_options + } + /// Adds a user fee increase to the document replace transition. /// /// The dedicated [`Self::user_fee_increase`] field is the single source diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index c960054f443..900a2f909c9 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -417,6 +417,7 @@ impl WasmSdk { // Extract signer from options let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Fetch the data contract (using cache) let data_contract = self.get_or_fetch_contract(contract_id).await?; @@ -426,7 +427,6 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use PutDocument trait for creation document @@ -537,6 +537,7 @@ impl WasmSdk { // Extract signer from options let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Fetch the data contract (using cache) let data_contract = self.get_or_fetch_contract(contract_id).await?; @@ -546,7 +547,6 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Use PutDocument trait for replacement (revision > INITIAL_REVISION triggers replace) document @@ -653,13 +653,13 @@ impl WasmSdk { // Extract signer from options let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Fetch the data contract (using cache) let data_contract = self.get_or_fetch_contract(contract_id).await?; // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build and execute delete transition using DocumentDeleteTransitionBuilder let builder = DocumentDeleteTransitionBuilder::new( @@ -826,6 +826,7 @@ impl WasmSdk { // Extract signer from options let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Fetch the data contract (using cache) let data_contract = self.get_or_fetch_contract(contract_id).await?; @@ -835,7 +836,6 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build, sign, and structurally validate the state transition without // broadcasting it. Local pre-broadcast failures are rolled back inside @@ -946,6 +946,7 @@ impl WasmSdk { // Extract signer from options let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Fetch the data contract (using cache) let data_contract = self.get_or_fetch_contract(contract_id).await?; @@ -955,7 +956,6 @@ impl WasmSdk { // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build, sign, and structurally validate the state transition without // broadcasting it. Local pre-broadcast failures are rolled back inside @@ -1066,13 +1066,13 @@ impl WasmSdk { // Extract signer from options let signer = IdentitySignerWasm::try_from_options(&options, "signer")?; + let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Fetch the data contract (using cache) let data_contract = self.get_or_fetch_contract(contract_id).await?; // Extract settings from options let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let token_payment_info = try_from_options_optional_token_payment_info(&options)?; // Build the delete transition using the builder's sign method (which does NOT broadcast) let builder = DocumentDeleteTransitionBuilder::new( diff --git a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts index 249c9d4acee..c7fea7060b5 100644 --- a/packages/wasm-sdk/tests/unit/prepare-document.spec.ts +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -12,6 +12,7 @@ import init, * as sdk from '../../dist/sdk.compressed.js'; * The WasmSdk is constructed via `WasmSdkBuilder.testnet().build()`, which * does not require a live platform — we expect every call here to reject * before any `get_or_fetch_contract` call runs. + * */ const DUMMY_ID = '11111111111111111111111111111111'; @@ -49,6 +50,17 @@ function buildDocument(overrides: Record = {}) { }); } +function forgeWasmLike>(type: string, value: T): T & { + __type: string; + __wbg_ptr: number; +} { + return { + ...value, + __type: type, + __wbg_ptr: 0xdeadbeef, + }; +} + describe('prepareDocument* validation', function describePrepareDocumentValidation() { this.timeout(30000); @@ -135,32 +147,29 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); - it('accepts a plain-object document and applies the same validation', async () => { - // Regression test for the safe structural extraction path. The - // prepare API must accept a plain `{...}` document shape *without* - // dereferencing wasm-bindgen internals — but it must still enforce - // the same revision guard a real `Document` instance hits. Hand the - // SDK a plain object with revision 2 and assert it routes to the - // revision guard, not the `__wbg_ptr` fast path. + it('rejects a forged Document-shaped object via the normal id/entropy validation path', async () => { + // Regression test for the safe structural extraction path. A forged + // public object that spoofs `__type` / `__wbg_ptr` must still be + // treated structurally and hit the normal create validation path, + // not a wasm pointer conversion fast path. const { signer, identityKey } = buildSigner(); - const document = { + const document = forgeWasmLike('Document', { id: DUMMY_ID, ownerId: DUMMY_ID_2, dataContractId: DUMMY_ID, documentTypeName: 'note', properties: { message: 'hello' }, - revision: 2, + revision: 1, entropy: new Uint8Array(32), - }; + }); try { await client.prepareDocumentCreate({ document, identityKey, signer }); - expect.fail('expected prepareDocumentCreate to reject revision > 1'); + expect.fail('expected prepareDocumentCreate to reject mismatched id/entropy'); } catch (e) { expect(e).to.be.instanceOf(sdk.WasmSdkError); expect(e.name).to.equal('InvalidArgument'); - expect(e.message).to.match(/revision/i); - expect(e.message).to.match(/prepareDocumentReplace/); + expect(e.message).to.match(/does not match/i); } }); @@ -225,22 +234,20 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); - it('accepts a plain-object document and applies the same validation', async () => { - // Regression test for the safe structural extraction path. The - // prepare API must accept a plain `{...}` document shape *without* - // dereferencing wasm-bindgen internals — but it must still enforce - // the same revision guard a real `Document` instance hits. Hand the - // SDK a plain object with revision 1 and assert it routes to the - // replace revision guard. + it('rejects a forged Document-shaped object via the normal revision validation path', async () => { + // Regression test for the safe structural extraction path. A forged + // public object that spoofs `__type` / `__wbg_ptr` must still be + // treated structurally and hit the replace revision guard, not a + // wasm pointer conversion fast path. const { signer, identityKey } = buildSigner(); - const document = { + const document = forgeWasmLike('Document', { id: DUMMY_ID, ownerId: DUMMY_ID_2, dataContractId: DUMMY_ID, documentTypeName: 'note', properties: { message: 'hello' }, revision: 1, - }; + }); try { await client.prepareDocumentReplace({ document, identityKey, signer }); @@ -296,16 +303,16 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); - it('rejects a plain object with no id', async () => { + it('rejects a forged Document-shaped object with no id', async () => { const { signer, identityKey } = buildSigner(); try { await client.prepareDocumentDelete({ - document: { + document: forgeWasmLike('Document', { ownerId: DUMMY_ID_2, dataContractId: DUMMY_ID, documentTypeName: 'note', - }, + }), identityKey, signer, }); @@ -317,16 +324,16 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); - it('rejects a plain object with no ownerId', async () => { + it('rejects a forged Document-shaped object with no ownerId', async () => { const { signer, identityKey } = buildSigner(); try { await client.prepareDocumentDelete({ - document: { + document: forgeWasmLike('Document', { id: DUMMY_ID, dataContractId: DUMMY_ID, documentTypeName: 'note', - }, + }), identityKey, signer, }); @@ -338,16 +345,16 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); - it('rejects a plain object with no dataContractId', async () => { + it('rejects a forged Document-shaped object with no dataContractId', async () => { const { signer, identityKey } = buildSigner(); try { await client.prepareDocumentDelete({ - document: { + document: forgeWasmLike('Document', { id: DUMMY_ID, ownerId: DUMMY_ID_2, documentTypeName: 'note', - }, + }), identityKey, signer, }); @@ -359,16 +366,16 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati } }); - it('rejects a plain object with no documentTypeName', async () => { + it('rejects a forged Document-shaped object with no documentTypeName', async () => { const { signer, identityKey } = buildSigner(); try { await client.prepareDocumentDelete({ - document: { + document: forgeWasmLike('Document', { id: DUMMY_ID, ownerId: DUMMY_ID_2, dataContractId: DUMMY_ID, - }, + }), identityKey, signer, }); @@ -397,6 +404,32 @@ describe('prepareDocument* validation', function describePrepareDocumentValidati expect(e.message).to.match(/identityKey/i); } }); + + it('rejects a forged TokenPaymentInfo-shaped object before contract fetch', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: { + id: DUMMY_ID, + ownerId: DUMMY_ID_2, + dataContractId: DUMMY_ID, + documentTypeName: 'note', + }, + identityKey, + signer, + tokenPaymentInfo: forgeWasmLike('TokenPaymentInfo', { + tokenContractPosition: 0, + paymentTokenContractId: {}, + }), + }); + expect.fail('expected prepareDocumentDelete to reject invalid tokenPaymentInfo'); + } catch (e) { + expect(e).to.be.instanceOf(sdk.WasmSdkError); + expect(e.name).to.equal('InvalidArgument'); + expect(e.message).to.match(/tokenPaymentInfo|paymentTokenContractId|identifier/i); + } + }); }); describe('documentCreate()/documentReplace()', () => {