From 291ded9a5d72f0c4ff450d70bb70f18303b28960 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 16 Feb 2026 19:03:56 -0600 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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()) }