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-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index b1dbb23d439..c449a5a546f 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -3,7 +3,9 @@ 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::documents::transitions::{ + build_signed_document_delete_transition, DocumentDeleteTransitionBuilder, +}; use dash_sdk::platform::IdentityPublicKey; use drive_proof_verifier::ContextProvider; use std::ffi::CStr; @@ -11,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::{ @@ -153,19 +155,33 @@ pub unsafe extern "C" fn dash_sdk_document_delete( builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign( - &wrapper.sdk, - identity_public_key, - signer, - wrapper.sdk.version(), - ) - .await - .map_err(|e| { - FFIError::InternalError(format!("Failed to create delete transition: {}", 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| map_document_sdk_error(e, "Failed to create delete transition"))?; + + // 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 = bincode::encode_to_vec(&state_transition, config).map_err(|e| { FFIError::InternalError(format!("Failed to serialize state transition: {}", e)) @@ -353,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-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/README.md b/packages/rs-sdk/README.md index 7a2372e0d4e..bcd8ce42c1d 100644 --- a/packages/rs-sdk/README.md +++ b/packages/rs-sdk/README.md @@ -46,6 +46,16 @@ 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. +- `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) 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. + + + 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/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/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/create.rs b/packages/rs-sdk/src/platform/documents/transitions/create.rs index bfcb7bc43be..3fde3b8a42b 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/create.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/create.rs @@ -1,11 +1,17 @@ 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; 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; @@ -25,9 +31,21 @@ 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 + /// [`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 { @@ -58,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, } } @@ -75,7 +95,27 @@ impl DocumentCreateTransitionBuilder { self } - /// Adds a user fee increase to the document create transition + /// 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 + /// 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 /// @@ -86,10 +126,33 @@ 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 } - /// 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, **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 /// @@ -99,11 +162,22 @@ impl DocumentCreateTransitionBuilder { /// /// * `Self` - The updated builder pub fn with_settings(mut self, settings: PutSettings) -> Self { - self.settings = Some(settings); + 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 } - /// 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 /// @@ -117,10 +191,20 @@ impl DocumentCreateTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + self.state_transition_creation_options_explicit = true; 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,20 +223,78 @@ 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, + platform_version, + )?; + } + + 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) .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, + platform_version, + )?; + let state_transition = BatchTransition::new_document_creation_transition_from_document( self.document.clone(), document_type, @@ -167,10 +309,202 @@ impl DocumentCreateTransitionBuilder { ) .await?; + ensure_valid_state_transition_structure(&state_transition, platform_version)?; + Ok(state_transition) } } +#[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)); + } + + /// 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. + #[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 { @@ -207,13 +541,57 @@ 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"); @@ -221,7 +599,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/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 2d44ec735a5..0ce097bd229 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; @@ -7,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; @@ -24,9 +26,17 @@ 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 + /// 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 { @@ -57,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, } } @@ -99,7 +111,27 @@ impl DocumentDeleteTransitionBuilder { self } - /// Adds a user fee increase to the document delete transition + /// 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 + /// 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 /// @@ -110,10 +142,33 @@ 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 } - /// Adds settings to the document delete transition + /// Adds settings to the document delete 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, **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 /// @@ -123,11 +178,22 @@ impl DocumentDeleteTransitionBuilder { /// /// * `Self` - The updated builder pub fn with_settings(mut self, settings: PutSettings) -> Self { - self.settings = Some(settings); + 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 } - /// Adds creation_options to the document delete transition + /// Adds creation_options to the document delete 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 /// @@ -141,10 +207,20 @@ impl DocumentDeleteTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + self.state_transition_creation_options_explicit = true; 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`]. 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 /// @@ -163,15 +239,51 @@ 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?; + 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 + /// 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) @@ -208,6 +320,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) } } @@ -219,12 +336,592 @@ 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 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 +/// 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?; + + match builder + .sign_with_nonce( + identity_contract_nonce, + identity_public_key, + signer, + sdk.version(), + ) + .await + { + Ok(transition) => Ok(transition), + Err(err) => { + sdk.rollback_identity_contract_nonce(owner_id, contract_id, identity_contract_nonce) + .await; + Err(err) + } + } +} + +#[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; + + /// 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 { + 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); + + // 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] + 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(); + 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() + }); + + // 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) + ); + // 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 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 explicit_setters_before_settings_keep_dedicated_fields_authoritative() { + 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) + ); + // 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, None); + assert_eq!(stored.state_transition_creation_options, None); + } + + /// Settings-first-then-explicit ordering: a later + /// [`DocumentDeleteTransitionBuilder::with_user_fee_increase`] call + /// 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_dedicated_field() { + 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"); + // 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 updates the dedicated field. The stored settings has the + /// field cleared. + #[test] + fn explicit_creation_options_after_settings_updates_dedicated_field() { + 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, None); + } + + /// `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 with_settings_preserves_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) + .with_user_fee_increase(42) + .with_state_transition_creation_options(explicit_options); + + // The dedicated fields reflect the explicit values. + assert_eq!(builder.user_fee_increase, Some(42)); + assert_eq!( + 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!( + 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 + /// 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 { /// Deletes an existing document from the platform. /// /// 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 @@ -249,14 +946,21 @@ impl Sdk { signing_key: &IdentityPublicKey, signer: &S, ) -> Result { - let platform_version = self.version(); - let put_settings = delete_document_transition_builder.settings; - let state_transition = delete_document_transition_builder - .sign(self, signing_key, signer, platform_version) - .await?; + // 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 + // 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/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/documents/transitions/replace.rs b/packages/rs-sdk/src/platform/documents/transitions/replace.rs index b40d27c15ea..1fc4068874c 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/replace.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/replace.rs @@ -1,11 +1,16 @@ 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; 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; @@ -23,9 +28,17 @@ 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 + /// 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 { @@ -53,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, } } @@ -70,7 +85,27 @@ impl DocumentReplaceTransitionBuilder { self } - /// Adds a user fee increase to the document replace transition + /// 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 + /// 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 /// @@ -81,10 +116,33 @@ 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 } - /// 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, **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 /// @@ -94,11 +152,22 @@ impl DocumentReplaceTransitionBuilder { /// /// * `Self` - The updated builder pub fn with_settings(mut self, settings: PutSettings) -> Self { - self.settings = Some(settings); + 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 } - /// 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 /// @@ -112,10 +181,20 @@ impl DocumentReplaceTransitionBuilder { creation_options: StateTransitionCreationOptions, ) -> Self { self.state_transition_creation_options = Some(creation_options); + self.state_transition_creation_options_explicit = true; 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,20 +213,60 @@ 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 - .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) .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, @@ -161,10 +280,160 @@ impl DocumentReplaceTransitionBuilder { ) .await?; + ensure_valid_state_transition_structure(&state_transition, platform_version)?; + Ok(state_transition) } } +#[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); + } + + /// 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() { + 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 { @@ -208,19 +477,59 @@ 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.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..b4337e77b23 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -1,3 +1,57 @@ +//! 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. +//! +//! # 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 +//! 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; use super::waitable::Waitable; @@ -6,15 +60,219 @@ 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; use dpp::tokens::token_payment_info::TokenPaymentInfo; +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::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. +/// +/// 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(()), + 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}" + ))), + } +} + +/// 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. +/// +/// 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!( + "document revision is {rev} but replace requires revision > \ + {INITIAL_REVISION}; use the create path for new documents" + ))), + None => Err(Error::InvalidArgument( + "document must have a revision set for replace; \ + use the create path for new documents" + .to_string(), + )), + } +} + +/// 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 dispatch site in this module. +/// +/// 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], + platform_version: &dpp::version::PlatformVersion, +) -> Result { + derive_document_id_from_parts( + &document_type.data_contract_id(), + owner_id, + document_type.name(), + 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`]. +/// +/// 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. +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( + 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 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 \ + 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, + document_state_transition_entropy: Option<[u8; 32]>, + 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 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, + platform_version, + ) + .map_err(Error::Protocol)?; + doc.set_id(id); + Ok((doc, entropy)) + } + } +} + #[async_trait::async_trait] /// A trait for putting a document to platform pub trait PutDocument>: Waitable { @@ -46,8 +304,453 @@ pub trait PutDocument>: Waitable { ) -> Result; } +/// Legacy create-or-replace dispatch: build, sign, and structurally validate +/// a document [`StateTransition`] without broadcasting it. +/// +/// **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`] / +/// [`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. +/// +/// # Behavior +/// +/// 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 +/// +/// The dispatch is driven by the document revision and rejects only the +/// 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 +/// +/// 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)] +#[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, + document_type: &DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + 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 + // 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(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 that enforces the create-path revision +/// boundary **and** the document-id-matches-entropy invariant 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. +/// +/// `document_state_transition_entropy` is required on the create path and +/// must match the entropy used to derive `document.id` via +/// [`Document::generate_document_id_v0`]. A mismatch is rejected here, +/// before the identity-contract nonce is allocated. +/// +/// Callers that want id auto-generation (legacy native behavior) should use +/// the [`PutDocument::put_to_platform`] trait method, which accepts +/// `entropy = None` and rewrites the document id before signing. +/// +/// On any pre-broadcast failure inside the dispatch (build, sign, or local +/// structure validation) the bumped identity-contract nonce is rolled back +/// so the local cache does not advance past a nonce the network never +/// observed. +#[allow(clippy::too_many_arguments)] +pub async fn build_signed_document_create_transition>( + sdk: &Sdk, + document: &Document, + document_type: &DocumentType, + document_state_transition_entropy: [u8; 32], + identity_public_key: &IdentityPublicKey, + token_payment_info: Option, + 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.as_ref(), &entropy, sdk.version())?; + + 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, + 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) + } + } +} + +/// 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 +/// without broadcasting it. +/// +/// This is a fail-fast wrapper 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. +/// +/// On any pre-broadcast failure inside the dispatch (build, sign, or local +/// structure validation) the bumped identity-contract nonce is rolled back +/// so the local cache does not advance past a nonce the network never +/// observed. +#[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 { + // Validate revision *before* allocating a nonce so caller mistakes + // never bump the local nonce cache. + ensure_revision_for_replace(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(owner_id, contract_id, true, settings) + .await?; + + let result = build_and_sign_replace_after_nonce( + sdk, + document, + document_type, + 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) + } + } +} + +/// 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. +/// 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( + 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) = resolve_document_create_entropy( + document, + document_type, + document_state_transition_entropy, + sdk.version(), + )?; + 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 { + /// 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. + /// + /// 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, @@ -58,64 +761,80 @@ 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? + // 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. + // + // 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. 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, sdk.version())?; + build_signed_document_create_transition_owned( + sdk, + resolved_document, + &document_type, + resolved_entropy, + &identity_public_key, + token_payment_info, + signer, + settings, + ) + .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, + // Replace path: entropy is unused; the strict helper enforces + // `revision > INITIAL_REVISION`. + build_signed_document_replace_transition( + sdk, + self, + &document_type, &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, + settings, ) .await? }; - ensure_valid_state_transition_structure(&transition, sdk.version())?; // 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) } @@ -144,3 +863,541 @@ 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(0))); + assert!(!is_document_replace_revision(Some(INITIAL_REVISION))); + 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"); + assert!(matches!(err, Error::InvalidArgument(_)), "err: {err:?}"); + let msg = err.to_string(); + 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!(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!(matches!(above, Error::InvalidArgument(_)), "err: {above:?}"); + 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!( + 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!(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!( + matches!(initial, Error::InvalidArgument(_)), + "err: {initial:?}" + ); + 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_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_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, + document_type.name(), + entropy.as_slice(), + ); + + 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, + 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(), + &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), + 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); + } + + /// 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]; + // 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 + // 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" + ); + } + + /// 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: + /// 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"); + + assert!(matches!(err, Error::InvalidArgument(_)), "err: {err:?}"); + let msg = err.to_string(); + 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" + ); + } +} diff --git a/packages/rs-sdk/src/platform/transition/put_settings.rs b/packages/rs-sdk/src/platform/transition/put_settings.rs index 2d1d58c273a..45facc2210a 100644 --- a/packages/rs-sdk/src/platform/transition/put_settings.rs +++ b/packages/rs-sdk/src/platform/transition/put_settings.rs @@ -27,3 +27,145 @@ 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, with_settings + /// overrides prior settings-derived values" contract: + /// + /// * 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 !user_fee_increase_explicit { + *dedicated_user_fee_increase = self.user_fee_increase; + } + if !state_transition_creation_options_explicit { + *dedicated_state_transition_creation_options = self.state_transition_creation_options; + } + self.user_fee_increase = None; + self.state_transition_creation_options = None; + 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 846d9ddae2d..b3a955cd861 100644 --- a/packages/rs-sdk/src/platform/transition/validation.rs +++ b/packages/rs-sdk/src/platform/transition/validation.rs @@ -2,41 +2,305 @@ use crate::Error; use dpp::{ consensus::{basic::BasicError, ConsensusError}, state_transition::{StateTransition, StateTransitionStructureValidation}, + validation::SimpleConsensusValidationResult, 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(_)) ) } +/// Convert a structure-validation result into [`Error`], with one special +/// case for [`UnsupportedFeatureError`]. +/// +/// `UnsupportedFeatureError` has *two* meanings in DPP: +/// +/// 1. **"Structure validation is not implemented for this state transition +/// 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"** — 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 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` 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(()); + } + + // 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 real-error / `UnsupportedFeatureError` case. The default + // `From for Error` conversion keeps + // 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_any_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. -pub(crate) fn ensure_valid_state_transition_structure( +/// `UnsupportedFeatureError` has two meanings in DPP — see +/// [`map_validation_result`] for the full discussion. In short: +/// +/// * 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 +/// 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, ) -> 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; + + /// 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("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, + ), + )) + } + + fn value_error(msg: &str) -> ConsensusError { + ConsensusError::BasicError(BasicError::ValueError(ValueError::new_from_string( + msg.to_string(), + ))) + } + + /// 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_sentinel_unsupported_errors_are_treated_as_ok() { + let result = SimpleConsensusValidationResult::new_with_errors(vec![ + 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() { + 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 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_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 error"); + let rendered = format!("{err}"); + assert!( + 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:?}"), + } + } + + /// 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 + /// 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/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index cad1f5e5103..14d2d821726 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -476,10 +476,61 @@ 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. + /// + /// # 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, + 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-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/README.md b/packages/wasm-sdk/README.md index 65451e43d98..6c872c67a73 100644 --- a/packages/wasm-sdk/README.md +++ b/packages/wasm-sdk/README.md @@ -175,6 +175,13 @@ 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`). +- `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. + ### Logging By default, the SDK is silent. You can enable tracing logs globally or via the builder: 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(), diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 0d36fa42a0c..900a2f909c9 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -1,20 +1,126 @@ //! 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). +//! +//! ## Nonce consumption +//! +//! 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 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. +//! +//! ### 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 +//! `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. +//! +//! ## 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. +//! +//! ## 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}; +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; 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::PutDocument; +use dash_sdk::platform::transition::put_document::{ + build_signed_document_create_transition, build_signed_document_replace_transition, + 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; use js_sys::Reflect; @@ -27,10 +133,11 @@ 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, + 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, }; use wasm_dpp2::IdentitySignerWasm; +use wasm_dpp2::StateTransitionWasm; #[wasm_bindgen(typescript_custom_section)] const TOKEN_PAYMENT_INFO_TS: &str = r#" @@ -81,14 +188,108 @@ 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()))?; + // 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. + // 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 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". Any other + // class marker is rejected below. + other => { + return Err(WasmSdkError::invalid_argument(format!( + "tokenPaymentInfo must be a plain DocumentTokenPaymentInfo options object \ + or a TokenPaymentInfo instance, got class '{other}'" + ))); + } + }; + let token_payment_info = TokenPaymentInfoWasm::constructor(options) + .map_err(|err| WasmSdkError::invalid_argument(err.to_string()))?; Ok(Some(token_payment_info.into())) } +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 [ + "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 // ============================================================================ @@ -104,6 +305,16 @@ 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(). + * + * **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; @@ -148,6 +359,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")] @@ -155,10 +370,15 @@ 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")?; + // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); let document_type_name = document_wasm.document_type_name(); @@ -177,6 +397,19 @@ 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, + self.inner_sdk().version(), + )?; + // Extract identity key from options let identity_key_wasm = IdentityPublicKeyWasm::try_from_options(&options, "identityKey")?; let identity_key: IdentityPublicKey = identity_key_wasm.into(); @@ -184,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?; @@ -193,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 @@ -226,7 +459,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; @@ -271,6 +506,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")] @@ -278,10 +517,15 @@ 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")?; + // Get metadata from document let contract_id: Identifier = document_wasm.data_contract_id().into(); let document_type_name = document_wasm.document_type_name(); @@ -293,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?; @@ -302,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 @@ -396,43 +640,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")?; @@ -441,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( @@ -477,6 +689,426 @@ 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. + * + * **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. + * + * 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. + * + * 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; + /** 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. + /// + /// **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 + #[wasm_bindgen(js_name = "prepareDocumentCreate")] + pub async fn prepare_document_create( + &self, + options: PrepareDocumentCreateOptionsJs, + ) -> Result { + // 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")?; + + // 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); + + // 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, + self.inner_sdk().version(), + )?; + + // 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")?; + + 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?; + + // 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, sign, and structurally validate the state transition without + // 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, + entropy_array, + &identity_key, + token_payment_info, + &signer, + 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. + * + * 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. + * + * 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; + /** 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. + /// + /// **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 + #[wasm_bindgen(js_name = "prepareDocumentReplace")] + pub async fn prepare_document_replace( + &self, + options: PrepareDocumentReplaceOptionsJs, + ) -> Result { + // 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")?; + + // 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")?; + + 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?; + + // 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, sign, and structurally validate the state transition without + // 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, + &identity_key, + token_payment_info, + &signer, + 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. + * + * 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. + * + * 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; + /** 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. + /// + /// **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 + #[wasm_bindgen(js_name = "prepareDocumentDelete")] + pub async fn prepare_document_delete( + &self, + options: PrepareDocumentDeleteOptionsJs, + ) -> Result { + // 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")?; + let identity_key: IdentityPublicKey = identity_key_wasm.into(); + + // 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); + + // 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(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 { + builder + }; + + // 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()) + } +} + // ============================================================================ // Document Transfer // ============================================================================ @@ -546,8 +1178,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 @@ -676,8 +1310,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 @@ -796,8 +1432,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 @@ -846,6 +1484,436 @@ 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. +/// +/// `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. +/// +/// # 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"))?; + + 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") + })?; + // Rebuild the typed `BTreeMap` 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| { + 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 = + 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 +/// platform-version-dispatched 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. +/// +/// Dispatch goes through the shared rs-sdk +/// [`derive_document_id_from_parts`] helper so the +/// `DocumentMethodVersions::derive_document_id` match lives in exactly +/// one place: an unknown future version surfaces here as an +/// `InvalidArgument` instead of silently using the v0 formula. The +/// rs-sdk strict create helper performs the same dispatch independently. +fn ensure_document_id_matches_entropy_fast( + document_id: Identifier, + contract_id: Identifier, + owner_id: Identifier, + document_type_name: &str, + entropy: &[u8; 32], + platform_version: &dash_sdk::dpp::version::PlatformVersion, +) -> Result<(), WasmSdkError> { + let expected = derive_document_id_from_parts( + &contract_id, + &owner_id, + document_type_name, + 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 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." + ))); + } + 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 { + // `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, + ))), + // `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(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, + ))), + } +} + /// Get an owned DocumentType from a DataContract fn get_document_type( data_contract: &dash_sdk::platform::DataContract, @@ -860,3 +1928,194 @@ fn get_document_type( )) }) } + +#[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; + use dash_sdk::platform::transition::validation::ensure_valid_state_transition_structure; + + #[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(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( + 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")); + } + + /// `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, + PlatformVersion::latest(), + ) + .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, + PlatformVersion::latest(), + ) + .expect_err("mismatch must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("does not match"), + "expected id-mismatch message, got: {msg}" + ); + } + + /// 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` + /// for identity-based state transitions (see rs-dpp `state_transition/mod.rs` + /// `StateTransitionStructureValidation` impl). rs-sdk intentionally allows + /// 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(); + let st: StateTransition = IdentityCreditTransferTransition::default_versioned(version) + .expect("default versioned ICT transition") + .into(); + assert!( + ensure_valid_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 d21315d33bc..9706a0246f8 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); @@ -340,6 +355,247 @@ 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; + } + + async function expectPreparedRebroadcastToBeIdempotent(st) { + const restored = reloadPreparedStateTransition(st); + + await client.broadcastStateTransition(restored); + await client.waitForResponse(restored); + + let secondBroadcastError; + try { + await client.broadcastStateTransition(restored); + await client.waitForResponse(restored); + } catch (e) { + 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(); + } + + 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); + }); + + 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, + }); + + await expectPreparedRebroadcastToBeIdempotent(prepared); + + 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); + }); + + 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); + + 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(); + + const replaceDoc = new sdk.Document({ + id: seedDoc.id, + properties: { message: 'prepare replace rebroadcast updated' }, + documentTypeName: 'mutableNote', + revision: 2, + dataContractId: testContractId, + ownerId: testData.identityId, + }); + + 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(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(); + }); + }); + describe('tokenPaymentInfo document flow', () => { it('should publish a contract with document token costs and fund the seller and buyer', async () => { const { signer: contractSigner, identityKey: contractIdentityKey } = createTestSignerAndKey(sdk, 1, 2); @@ -695,5 +951,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(); + }); }); }); 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..c7fea7060b5 --- /dev/null +++ b/packages/wasm-sdk/tests/unit/prepare-document.spec.ts @@ -0,0 +1,472 @@ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +/** + * 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) 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 + * 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, + }); +} + +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); + + 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 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 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 = forgeWasmLike('Document', { + id: DUMMY_ID, + ownerId: DUMMY_ID_2, + dataContractId: DUMMY_ID, + documentTypeName: 'note', + properties: { message: 'hello' }, + revision: 1, + entropy: new Uint8Array(32), + }); + + 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 () => { + // 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.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 0 is invalid for both create and replace/); + expect(e.message).to.not.match(/prepareDocumentReplace/); + expect(e.message).to.not.match(/prepareDocumentCreate/); + } + }); + }); + + 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 () => { + // 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('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 = 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 }); + 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/); + } + }); + + 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/); + } + }); + }); + + 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 forged Document-shaped object with no id', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: forgeWasmLike('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 forged Document-shaped object with no ownerId', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: forgeWasmLike('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 forged Document-shaped object with no dataContractId', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: forgeWasmLike('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 forged Document-shaped object with no documentTypeName', async () => { + const { signer, identityKey } = buildSigner(); + + try { + await client.prepareDocumentDelete({ + document: forgeWasmLike('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); + } + }); + + 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()', () => { + 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 }); + + 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 0 is invalid for both create and replace/); + expect(e.message).to.not.match(/documentCreate/); + expect(e.message).to.not.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/); + } + }); + }); +});