From 474281a0748db54f9dab64c1f0bb9eabf71b5673 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 20 Sep 2026 19:48:59 -0500 Subject: [PATCH 1/2] feat(wasm-dpp2)!: derive nonce-committed document ids in the JavaScript create path From protocol version 14 the id of a new document commits to the identity contract nonce of its create transition (#4859). Rust clients got the derivation through DocumentCreateTransitionV0::from_document; the wasm surface still copied the entropy-only v0 id the Document constructor produced into the transition, so every create built by hand in JavaScript was refused with InvalidDocumentTransitionIdError. DocumentCreateTransition now derives the id from the document's entropy and identityContractNonce through Document::generate_document_id for the platform version given (latest by default) and writes it back onto the caller's Document. Document.generateId takes the nonce and the platform version, requiring the nonce from version 14; Document gains setIdForCreation and an identityContractNonce constructor option; a Document built without either is documented as carrying a placeholder. wasm-dpp2 stops carrying its own copy of the v0 hash. wasm-sdk documentCreate mirrors the confirmed id onto the wasm object instead of through Reflect. Reading a wasm object out of an options bag now refuses one JS has already freed instead of dereferencing a null pointer. BREAKING CHANGE: Document.generateId(type, owner, contract, entropy) without an identityContractNonce throws at protocol version 14; new DocumentCreateTransition({document}) replaces the id the document carried and mutates that document. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/documents.md | 1 + book/src/sdk/put-operations.md | 10 + packages/js-evo-sdk/README.md | 17 ++ .../rs-platform-version/src/version/v14.rs | 9 +- packages/wasm-dpp2/README.md | 34 +++ .../src/data_contract/document/model.rs | 228 ++++++++++++++++-- .../batch/document_transitions/create.rs | 27 ++- packages/wasm-dpp2/src/utils.rs | 176 +++++++------- .../wasm-dpp2/tests/unit/Document.spec.ts | 155 +++++++++++- .../tests/unit/DocumentsTransitions.spec.ts | 75 ++++++ .../src/state_transitions/document.rs | 21 +- 11 files changed, 635 insertions(+), 118 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 34068e6d753..1276916b001 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -101,6 +101,7 @@ The ID of a new document only exists once the nonce of its create transition is - The ID a `Document` carries before its create transition is built (for example the one `create_document_from_data` gives it) is a **placeholder**. `DocumentCreateTransitionV0::from_document` replaces it with the derived ID, so every transition built through dpp carries the right one. - Read the ID from the transition, or from the confirmed document `put_to_platform_and_wait_for_response` returns, not from the document you passed in. - To know IDs up front (a chain of documents that reference each other), assign the nonces first: nonces may be used out of order within a window of 24. +- JavaScript gets the same through `wasm-dpp2`: `new DocumentCreateTransition({ document, identityContractNonce })` derives the ID for the network's protocol version (`platformVersion` option, latest by default), so the transition carries the right one and `document.id` is updated to match. `Document.generateId(type, owner, contract, entropy, identityContractNonce)` and `document.setIdForCreation(identityContractNonce)` give the ID before the transition exists, and `new Document({ ..., identityContractNonce })` derives it at construction. A `Document` built without a nonce carries the entropy-only placeholder. No app needs to reimplement the hash. ## The Accessor Traits diff --git a/book/src/sdk/put-operations.md b/book/src/sdk/put-operations.md index 67eb6c142fe..5c37b5413d0 100644 --- a/book/src/sdk/put-operations.md +++ b/book/src/sdk/put-operations.md @@ -151,6 +151,16 @@ so the SDK switches derivation when the network does. Because the ID depends on nonce, the ID on the document you pass in is a placeholder: use the ID of the confirmed document that `put_to_platform_and_wait_for_response` returns. +The JavaScript SDK follows the same pipeline. `sdk.documents.create` goes through +`put_to_platform_and_wait_for_response` and hands the confirmed document back. An app +that builds the transition itself (to sign it separately or cache the signed bytes) +gets the derivation from `wasm-dpp2`: `new DocumentCreateTransition({ document, +identityContractNonce })` derives the ID from the document's entropy and the nonce for +the network's protocol version (`platformVersion` option, latest by default), writes it +onto the transition and back onto `document`, and the transition is then batched, signed +and broadcast as before. The IDs such a transition carries are final; nothing has to be +hashed on the app side. + ### Step 3: Validate Structure Before broadcasting, the SDK validates the transition's basic structure: diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 9d93e99aff3..d8a36204c73 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -16,6 +16,7 @@ Evo SDK provides a high-level, strongly-typed interface for interacting with [Da - [Facades](#facades) - [Ranked queries](#ranked-queries) - [Document references (`refersTo`)](#document-references-refersto) +- [Building a document create transition by hand](#building-a-document-create-transition-by-hand) - [Immutable properties (`immutable`)](#immutable-properties-immutable) - [Chained queries (provable semi-join)](#chained-queries-provable-semi-join) - [Composite queries (a page plus its sub-queries)](#composite-queries-a-page-plus-its-sub-queries) @@ -224,6 +225,22 @@ try { } ``` +## Building a document create transition by hand + +`sdk.documents.create` fetches the nonce, builds, signs, broadcasts and returns the confirmed document, whose `id` is the one Platform stored. An app that needs the signed transition itself (to broadcast later, or to cache the signed bytes) builds it from the re-exported `wasm-dpp2` classes: + +```ts +import { Document, DocumentCreateTransition, BatchTransition } from '@dashevo/evo-sdk'; + +const document = new Document({ properties, documentTypeName, dataContractId, ownerId }); +const transition = new DocumentCreateTransition({ document, identityContractNonce: nonce }); +const batch = BatchTransition.fromBatchedTransitions([transition.toDocumentTransition()], ownerId, 0); // userFeeIncrease +const stateTransition = batch.toStateTransition(); +// sign, then sdk.stateTransitions.broadcast(stateTransition) +``` + +From protocol version 14 the id of a new document commits to the identity contract nonce of its create transition. `new DocumentCreateTransition(...)` derives that id from the document's entropy and `identityContractNonce`, puts it on the transition and writes it back onto `document`, so `document.id` is final once the transition exists and equals `transition.base.id`. Before that the `Document` carries a placeholder. To know the id earlier, `document.setIdForCreation(nonce)` or `Document.generateId(type, owner, contract, entropy, nonce)`, or pass `identityContractNonce` to the `Document` constructor. Pass `platformVersion` (defaults to latest) to any of them for a network on an earlier protocol version. No app needs to reimplement the hash. + ## Immutable properties (`immutable`) From protocol version 14 a mutable document type can freeze some of its top-level properties at creation with the doctype-level `immutable` list, while the rest of the document stays replaceable. A second list, `immutableAllowSetting`, names the frozen properties a replace may still set while the stored document has no value for them; once present they are frozen too. Both are consensus-enforced on every replace, and a fetched contract can be asked what it declares: diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 16fb299b088..345d23f805c 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -402,7 +402,14 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// Ids of documents created before the upgrade can not be produced by /// the new derivation either. A client that still derives the entropy /// only id has every create rejected with -/// `InvalidDocumentTransitionIdError`. +/// `InvalidDocumentTransitionIdError`. Every create path of the clients +/// in this repository derives through `Document::generate_document_id`: +/// `DocumentCreateTransitionV0::from_document` for dpp, rs-sdk and the +/// bindings built on them, and in wasm-dpp2 the `DocumentCreateTransition` +/// constructor (which also writes the id back onto the JavaScript +/// `Document`), `Document.generateId` with its `identityContractNonce` +/// argument, `setIdForCreation` and the `identityContractNonce` +/// constructor option. /// 19. **Document deletion by moderators**: a document type of a contract /// that declares moderation may set `canBeDeletedByModerators` (meta-schema /// v3, fixed when the type is created, refused on a type that keeps diff --git a/packages/wasm-dpp2/README.md b/packages/wasm-dpp2/README.md index 4f821701242..95762b77ae1 100644 --- a/packages/wasm-dpp2/README.md +++ b/packages/wasm-dpp2/README.md @@ -11,3 +11,37 @@ Internal build of the Dash Platform Protocol v2 WebAssembly bindings. The build scripts defer to `packages/scripts/build-wasm.sh` to keep behaviour consistent with other WASM packages such as `@dashevo/wasm-sdk`. + +## Document ids + +From protocol version 14 the id of a new document commits to the identity +contract nonce of its create transition (see the book, *Data Model → +Documents → Document ID Generation*), so a `Document` built with +`new Document({...})` and no `identityContractNonce` carries a **placeholder** +id: the entropy-only derivation of earlier versions, which consensus no longer +accepts. + +The id becomes final where the nonce is known, and the bindings derive it for +you: + +```ts +const document = new Document({ properties, documentTypeName, dataContractId, ownerId }); + +// Derives the id from the document's entropy and the nonce, writes it onto +// the transition and back onto `document`: document.id equals transition.base.id. +const transition = new DocumentCreateTransition({ document, identityContractNonce: nonce }); + +// To know the id before the transition exists (another document in the same +// batch references it): +document.setIdForCreation(nonce); +// or derive it without a Document: +const idBytes = Document.generateId(documentTypeName, ownerId, dataContractId, entropy, nonce); +// or build the document with its final id from the start: +const ready = new Document({ properties, documentTypeName, dataContractId, ownerId, identityContractNonce: nonce }); +``` + +Each of these takes an optional `platformVersion` (latest by default); before +protocol version 14 the derivation ignores the nonce. Whatever id a `Document` +carried before it is passed to `DocumentCreateTransition` is replaced: the +transition can only carry the id consensus recomputes. No app needs to +reimplement the hash. diff --git a/packages/wasm-dpp2/src/data_contract/document/model.rs b/packages/wasm-dpp2/src/data_contract/document/model.rs index 525119515cb..44ba47361b4 100644 --- a/packages/wasm-dpp2/src/data_contract/document/model.rs +++ b/packages/wasm-dpp2/src/data_contract/document/model.rs @@ -6,8 +6,8 @@ use crate::impl_try_from_options; use crate::impl_wasm_type_info; use crate::serialization; use crate::utils::{ - ToSerdeJSONExt, try_from_options, try_from_options_optional, try_from_options_with, - try_vec_to_fixed_bytes, + ToSerdeJSONExt, try_from_options, try_from_options_optional, try_from_options_optional_with, + try_from_options_with, try_to_u64, try_vec_to_fixed_bytes, }; use crate::version::{PlatformVersionLikeJs, PlatformVersionWasm}; use dpp::document::serialization_traits::{ @@ -23,8 +23,10 @@ use dpp::identifier::Identifier; use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; use dpp::platform_value::string_encoding::encode; use dpp::platform_value::{Value, ValueMapHelper}; +use dpp::prelude::IdentityNonce; use dpp::util::entropy_generator; use dpp::util::entropy_generator::EntropyGenerator; +use dpp::version::PlatformVersion; use serde::Deserialize; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -46,10 +48,25 @@ export interface DocumentOptions { ownerId: IdentifierLike; /** Document revision (default: 1n) */ revision?: bigint; - /** Document ID (auto-generated if not provided) */ + /** + * Document ID. Derived when not provided (see `identityContractNonce`). + * Whatever is given here is replaced by `new DocumentCreateTransition(...)`, + * which can only carry the id consensus recomputes. + */ id?: IdentifierLike; /** Entropy bytes (32 bytes, auto-generated if not provided) */ entropy?: Uint8Array; + /** + * Identity contract nonce the create transition of this document is going + * to use. From protocol version 14 the id of a new document commits to + * that nonce, so without it the derived `id` is a placeholder that + * `new DocumentCreateTransition(...)` replaces (and mirrors back onto this + * document). Pass the nonce here, or call `setIdForCreation`, to have the + * final id from the start. + */ + identityContractNonce?: bigint; + /** Platform version the id is derived for (default: latest) */ + platformVersion?: PlatformVersionLike; } /** @@ -161,6 +178,38 @@ impl DocumentWasm { pub fn set_data_contract_id(&mut self, data_contract_id: &IdentifierWasm) { self.data_contract_id = *data_contract_id; } + + /// Gives the document the id its create transition will carry: what + /// `DocumentCreateTransitionV0::from_document` does in dpp, for a + /// document that carries its entropy and knows its contract and type + /// itself. + /// + /// Errors when the document has no entropy, which is the case for one + /// deserialized from Platform: such a document already exists and can + /// not be created. + pub fn set_id_for_creation( + &mut self, + identity_contract_nonce: IdentityNonce, + platform_version: &PlatformVersion, + ) -> WasmDppResult<()> { + let entropy = self.entropy.ok_or_else(|| { + WasmDppError::invalid_argument( + "document has no entropy: only a document built with `new Document(...)` can be \ + created", + ) + })?; + + let id = Document::generate_document_id( + &self.data_contract_id.into(), + &self.document.owner_id(), + &self.document_type_name, + &entropy, + identity_contract_nonce, + platform_version, + )?; + self.document.set_id(id); + Ok(()) + } } #[wasm_bindgen] @@ -202,6 +251,16 @@ impl DocumentWasm { let id: Option = try_from_options_optional(&options, "id")?; + let identity_contract_nonce: Option = + try_from_options_optional_with(&options, "identityContractNonce", |v| { + try_to_u64(v, "identityContractNonce") + })?; + + let platform_version: PlatformVersion = + try_from_options_optional::(&options, "platformVersion")? + .unwrap_or_default() + .into(); + let properties = try_from_options_with(&options, "properties", |v| { v.with_serde_to_platform_value_map() })?; @@ -222,17 +281,26 @@ impl DocumentWasm { Ok, )?; - let doc_id: Identifier = id.map_or_else( - || { - crate::utils::generate_document_id_v0( - &data_contract_id, - &owner_id, - &document_type_name, - &entropy, - ) - }, - |id| Ok(id.into()), - )?; + let doc_id: Identifier = match (id, identity_contract_nonce) { + (Some(id), _) => id.into(), + (None, Some(identity_contract_nonce)) => Document::generate_document_id( + &data_contract_id, + &owner_id, + &document_type_name, + &entropy, + identity_contract_nonce, + &platform_version, + )?, + // Without the nonce of the create transition the id can only be + // the entropy-only one, which from protocol version 14 is a + // placeholder: `DocumentCreateTransition` replaces it. + (None, None) => Document::generate_document_id_v0( + &data_contract_id, + &owner_id, + &document_type_name, + &entropy, + ), + }; let document = Document::V0(DocumentV0 { contract_version: None, @@ -359,6 +427,34 @@ impl DocumentWasm { Ok(()) } + /// Gives the document the id its create transition will carry, derived + /// from its entropy and the identity contract nonce that transition is + /// going to use. + /// + /// `new DocumentCreateTransition(...)` does this on its own; call it + /// yourself to know the id before the transition exists (a document that + /// another document in the same batch references). The transition must + /// then be built with the same nonce. + /// + /// Throws when the document carries no entropy (one read back from + /// Platform), since only a new document can be created. + #[wasm_bindgen(js_name = "setIdForCreation")] + pub fn set_id_for_creation_js( + &mut self, + #[wasm_bindgen(js_name = "identityContractNonce")] identity_contract_nonce: u64, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: Option< + PlatformVersionLikeJs, + >, + ) -> WasmDppResult<()> { + let platform_version: PlatformVersion = platform_version + .map(PlatformVersionWasm::try_from) + .transpose()? + .unwrap_or_default() + .into(); + + self.set_id_for_creation(identity_contract_nonce, &platform_version) + } + #[wasm_bindgen(setter=entropy)] pub fn set_entropy(&mut self, entropy: Option>) -> WasmDppResult<()> { match entropy { @@ -721,12 +817,23 @@ impl DocumentWasm { ) } + /// Derives the id of a document that is about to be created, the way + /// consensus recomputes it for the create transition. + /// + /// From protocol version 14 the id commits to the identity contract + /// nonce of the create transition, so `identityContractNonce` is + /// required at that version (and ignored before it). The platform + /// version defaults to the latest. #[wasm_bindgen(js_name = "generateId")] pub fn generate_id( #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: &str, #[wasm_bindgen(js_name = "ownerId")] owner_id: IdentifierLikeJs, #[wasm_bindgen(js_name = "dataContractId")] data_contract_id: IdentifierLikeJs, entropy: Option>, + #[wasm_bindgen(js_name = "identityContractNonce")] identity_contract_nonce: Option, + #[wasm_bindgen(js_name = "platformVersion")] platform_version: Option< + PlatformVersionLikeJs, + >, ) -> WasmDppResult> { let owner_id: Identifier = owner_id.try_into()?; let data_contract_id: Identifier = data_contract_id.try_into()?; @@ -738,11 +845,30 @@ impl DocumentWasm { .map_err(|err| WasmDppError::serialization(err.to_string()))?, }; - let identifier = crate::utils::generate_document_id_v0( + let platform_version: PlatformVersion = platform_version + .map(PlatformVersionWasm::try_from) + .transpose()? + .unwrap_or_default() + .into(); + + let identity_contract_nonce = match identity_contract_nonce { + Some(identity_contract_nonce) => identity_contract_nonce, + None if Document::document_id_depends_on_nonce(&platform_version)? => { + return Err(WasmDppError::invalid_argument( + "'identityContractNonce' is required: from protocol version 14 the id of a \ + new document commits to the identity contract nonce of its create transition", + )); + } + None => 0, + }; + + let identifier = Document::generate_document_id( &data_contract_id, &owner_id, document_type_name, &entropy_bytes, + identity_contract_nonce, + &platform_version, )?; Ok(identifier.to_vec()) @@ -798,5 +924,77 @@ impl DocumentWasm { } impl_try_from_js_value!(DocumentWasm, "Document"); + impl_try_from_options!(DocumentWasm); impl_wasm_type_info!(DocumentWasm, Document); + +#[cfg(test)] +mod tests { + use super::*; + use dpp::platform_value::string_encoding::Encoding; + use std::collections::BTreeMap; + + fn note(entropy: Option<[u8; 32]>) -> DocumentWasm { + let document = Document::V0(DocumentV0 { + id: Identifier::from([9u8; 32]), + owner_id: Identifier::from([2u8; 32]), + properties: BTreeMap::new(), + revision: Some(1), + ..Default::default() + }); + DocumentWasm::new( + document, + Identifier::from([1u8; 32]), + "note".to_string(), + entropy, + ) + } + + #[test] + fn set_id_for_creation_derives_the_id_dpp_pins() { + // The vector `Document::generate_document_id_v1` pins in rs-dpp; the + // wasm wrapper must feed its own contract id, type name and entropy + // into the same derivation. + let mut document = note(Some([7u8; 32])); + + document + .set_id_for_creation(1, PlatformVersion::latest()) + .expect("expected an id"); + + assert_eq!( + document.document.id().to_string(Encoding::Hex), + "e574ae73396611a517691d1f89275b6e99642cb9c176ce8cf879b1665c50f15f" + ); + } + + #[test] + fn set_id_for_creation_keeps_the_entropy_only_id_before_protocol_version_14() { + let mut document = note(Some([7u8; 32])); + let version_13 = PlatformVersion::get(13).expect("expected version 13"); + + document + .set_id_for_creation(1, version_13) + .expect("expected an id"); + + assert_eq!( + document.document.id(), + Document::generate_document_id_v0( + &Identifier::from([1u8; 32]), + &Identifier::from([2u8; 32]), + "note", + &[7u8; 32], + ) + ); + } + + #[test] + fn set_id_for_creation_refuses_a_document_without_entropy() { + let mut document = note(None); + + let error = document + .set_id_for_creation(1, PlatformVersion::latest()) + .expect_err("expected an error"); + + assert!(error.to_string().contains("entropy"), "{error}"); + } +} diff --git a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs index dc366241ace..b554986c617 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs @@ -9,25 +9,36 @@ use crate::state_transitions::batch::generators::generate_create_transition; use crate::state_transitions::batch::prefunded_voting_balance::PrefundedVotingBalanceWasm; use crate::state_transitions::batch::token_payment_info::TokenPaymentInfoWasm; use crate::utils::{ - try_from_options, try_from_options_optional, try_from_options_with, try_to_u64, + try_from_options_mut, try_from_options_optional, try_from_options_with, try_to_u64, try_vec_to_fixed_bytes, ToSerdeJSONExt, }; +use crate::version::PlatformVersionWasm; use dpp::prelude::IdentityNonce; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use dpp::state_transition::batch_transition::DocumentCreateTransition; +use dpp::version::PlatformVersion; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; #[wasm_bindgen(typescript_custom_section)] const DOCUMENT_CREATE_OPTIONS_TS: &str = r#" export interface DocumentCreateTransitionOptions { + /** + * The document to create. Its id is derived here from its entropy and + * `identityContractNonce` (from protocol version 14 the id of a new + * document commits to that nonce), replacing whatever id the document + * carried, and written back onto this object: after construction + * `document.id` equals `transition.base.id`. + */ document: Document; identityContractNonce: bigint; prefundedVotingBalance?: PrefundedVotingBalance; tokenPaymentInfo?: TokenPaymentInfo; actionFeeAgreement?: DocumentActionFeeAgreement; + /** Platform version the id is derived for (default: latest) */ + platformVersion?: PlatformVersionLike; } "#; @@ -62,13 +73,23 @@ impl DocumentCreateTransitionWasm { pub fn constructor( options: DocumentCreateTransitionOptionsJs, ) -> WasmDppResult { - let document: DocumentWasm = try_from_options(&options, "document")?; - let identity_contract_nonce: IdentityNonce = try_from_options_with(&options, "identityContractNonce", |v| { try_to_u64(v, "identityContractNonce") })?; + let platform_version: PlatformVersion = + try_from_options_optional::(&options, "platformVersion")? + .unwrap_or_default() + .into(); + + // The id a document carries before its nonce is known is a + // placeholder: derive the one consensus will recompute, on the + // caller's own object so `document.id` matches `transition.base.id`, + // as `DocumentCreateTransitionV0::from_document` does in dpp. + let mut document = try_from_options_mut::(&options, "document", "Document")?; + document.set_id_for_creation(identity_contract_nonce, &platform_version)?; + let prefunded_voting_balance: Option = try_from_options_optional(&options, "prefundedVotingBalance")?; diff --git a/packages/wasm-dpp2/src/utils.rs b/packages/wasm-dpp2/src/utils.rs index 472a5d2b70b..b55e68631bf 100644 --- a/packages/wasm-dpp2/src/utils.rs +++ b/packages/wasm-dpp2/src/utils.rs @@ -1,12 +1,10 @@ use crate::error::{WasmDppError, WasmDppResult}; -use dpp::identifier::Identifier; use dpp::platform_value::Value; -use dpp::util::hash::hash_double_to_vec; use js_sys::{Error as JsError, Map, Object}; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::convert::TryInto; -use wasm_bindgen::convert::RefFromWasmAbi; +use wasm_bindgen::convert::{RefFromWasmAbi, RefMutFromWasmAbi}; use wasm_bindgen::{JsCast, JsValue}; /// Extension trait for extracting error messages from JsValue @@ -115,6 +113,13 @@ pub fn generic_of_js_val>( js_value: &JsValue, class_name: &str, ) -> WasmDppResult { + let ptr = wasm_ptr_of_js_val(js_value, class_name)?; + Ok(unsafe { T::ref_from_abi(ptr) }) +} + +/// The internal wasm pointer of a JS object, once it is confirmed to be an +/// instance of the expected wasm class. +fn wasm_ptr_of_js_val(js_value: &JsValue, class_name: &str) -> WasmDppResult { if !js_value.is_object() { return Err(WasmDppError::invalid_argument(format!( "Value supplied as {} is not an object", @@ -124,28 +129,36 @@ pub fn generic_of_js_val>( let ctor_name = get_class_type(js_value)?; - if ctor_name == class_name { - let ptr = - js_sys::Reflect::get(js_value, &JsValue::from_str("__wbg_ptr")).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read internal pointer from JS object '{}': {}", - class_name, message - )) - })?; - let ptr_u32: u32 = ptr - .as_f64() - .ok_or_else(|| WasmDppError::invalid_argument("Invalid JS object pointer"))? - as u32; - let reference = unsafe { T::ref_from_abi(ptr_u32) }; - Ok(reference) - } else { - let error_string = format!( + if ctor_name != class_name { + return Err(WasmDppError::invalid_argument(format!( "JS object constructor name mismatch. Expected {}, provided {}.", class_name, ctor_name - ); - Err(WasmDppError::invalid_argument(error_string)) + ))); + } + + let ptr = js_sys::Reflect::get(js_value, &JsValue::from_str("__wbg_ptr")).map_err(|err| { + let message = err.error_message(); + WasmDppError::generic(format!( + "failed to read internal pointer from JS object '{}': {}", + class_name, message + )) + })?; + + let ptr = ptr + .as_f64() + .ok_or_else(|| WasmDppError::invalid_argument("Invalid JS object pointer"))? + as u32; + + // wasm-bindgen writes 0 into `__wbg_ptr` when JS frees the object (or + // moves it into wasm); dereferencing it would trap instead of erroring. + if ptr == 0 { + return Err(WasmDppError::invalid_argument(format!( + "JS object '{}' has already been freed", + class_name + ))); } + + Ok(ptr) } /// Get the `__type` property from a JsValue (used for WASM class identification) @@ -161,6 +174,31 @@ pub fn get_class_type(value: &JsValue) -> WasmDppResult { Ok(class_type.as_string().unwrap_or_default()) } +/// The raw value of a property of a JS options object. +fn read_property(options: &JsValue, property_name: &str) -> WasmDppResult { + js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { + let message = err.error_message(); + WasmDppError::generic(format!( + "failed to read '{}' from options: {}", + property_name, message + )) + }) +} + +/// The value of a required property, rejected when it is undefined or null. +fn read_required_property(options: &JsValue, property_name: &str) -> WasmDppResult { + let value = read_property(options, property_name)?; + + if value.is_undefined() || value.is_null() { + return Err(WasmDppError::invalid_argument(format!( + "'{}' is required", + property_name + ))); + } + + Ok(value) +} + /// Extract a required property from a JS value/object and convert it using TryFrom. /// /// Returns `Err` if the property is undefined, null, or conversion fails. @@ -177,21 +215,7 @@ where for<'a> T: TryFrom<&'a JsValue>, for<'a> >::Error: Into, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; - - if value.is_undefined() || value.is_null() { - return Err(WasmDppError::invalid_argument(format!( - "'{}' is required", - property_name - ))); - } + let value = read_required_property(options, property_name)?; T::try_from(&value).map_err(Into::into) } @@ -217,25 +241,37 @@ pub fn try_from_options_with( where F: FnOnce(&JsValue) -> WasmDppResult, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; - - if value.is_undefined() || value.is_null() { - return Err(WasmDppError::invalid_argument(format!( - "'{}' is required", - property_name - ))); - } + let value = read_required_property(options, property_name)?; converter(&value) } +/// Borrow a required wasm-class property of a JS object mutably, so that a +/// change made through it lands on the caller's own object rather than on a +/// clone. +/// +/// Returns `Err` if the property is undefined or null, or is not an instance +/// of the wasm class named `class_name`. The borrow panics, as any +/// wasm-bindgen method on the object would, if the object is already +/// borrowed: drop it before handing the object back to JS. +/// +/// # Example +/// +/// ```ignore +/// let mut document = try_from_options_mut::(&options, "document", "Document")?; +/// document.set_id(id); +/// ``` +pub fn try_from_options_mut>( + options: &JsValue, + property_name: &str, + class_name: &str, +) -> WasmDppResult { + let value = read_required_property(options, property_name)?; + + let ptr = wasm_ptr_of_js_val(&value, class_name)?; + Ok(unsafe { T::ref_mut_from_abi(ptr) }) +} + /// Extract an optional property from a JS object and convert it using TryFrom. /// /// Returns `Ok(None)` if the property is undefined or null. @@ -257,14 +293,7 @@ where for<'a> T: TryFrom<&'a JsValue>, for<'a> >::Error: Into, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; + let value = read_property(options, property_name)?; if value.is_undefined() || value.is_null() { Ok(None) @@ -296,14 +325,7 @@ pub fn try_from_options_optional_with( where F: FnOnce(&JsValue) -> WasmDppResult, { - let value = - js_sys::Reflect::get(options, &JsValue::from_str(property_name)).map_err(|err| { - let message = err.error_message(); - WasmDppError::generic(format!( - "failed to read '{}' from options: {}", - property_name, message - )) - })?; + let value = read_property(options, property_name)?; if value.is_undefined() || value.is_null() { Ok(None) @@ -701,24 +723,6 @@ pub fn try_to_u16(value: &JsValue, field_name: &str) -> WasmDppResult { } } -/// Generate a document ID using the v0 algorithm -pub fn generate_document_id_v0( - contract_id: &Identifier, - owner_id: &Identifier, - document_type_name: &str, - entropy: &[u8], -) -> WasmDppResult { - let mut buf: Vec = vec![]; - - buf.extend_from_slice(&contract_id.to_buffer()); - buf.extend_from_slice(&owner_id.to_buffer()); - buf.extend_from_slice(document_type_name.as_bytes()); - buf.extend_from_slice(entropy); - - Identifier::from_bytes(&hash_double_to_vec(&buf)) - .map_err(|err| WasmDppError::invalid_argument(err.to_string())) -} - /// Macro to implement `TryFrom<&JsValue>` for WASM wrapper types using `IntoWasm`. /// /// This is for complex types that can only be instantiated from their WASM class objects diff --git a/packages/wasm-dpp2/tests/unit/Document.spec.ts b/packages/wasm-dpp2/tests/unit/Document.spec.ts index 82b5d3a88e9..bf4807e6fbc 100644 --- a/packages/wasm-dpp2/tests/unit/Document.spec.ts +++ b/packages/wasm-dpp2/tests/unit/Document.spec.ts @@ -11,7 +11,7 @@ import { document2, documentBytes, } from './mocks/Document/index.js'; -import { fromHexString } from './utils/hex.ts'; +import { fromHexString, toHexString } from './utils/hex.ts'; let PlatformVersion: typeof wasm.PlatformVersion; @@ -76,6 +76,47 @@ describe('Document', () => { expect(documentInstance).to.be.an.instanceof(wasm.Document); }); + + it('should derive the entropy-only placeholder id when no nonce is given', () => { + const documentInstance = createDocument({ entropy: fixedEntropy }); + + // the pre-14 derivation, which is all that can be computed without the + // nonce of the create transition + const placeholder = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + undefined, + 13, + ); + + expect(documentInstance.id.toBytes()).to.deep.equal(placeholder); + }); + + it('should derive the final id when the identity contract nonce is given', () => { + const documentInstance = new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + entropy: fixedEntropy, + identityContractNonce: BigInt(5), + }); + + const expected = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + BigInt(5), + ); + + expect(documentInstance.id.toBytes()).to.deep.equal(expected); + expect(documentInstance.id.toBytes()).to.not.deep.equal( + createDocument({ entropy: fixedEntropy }).id.toBytes(), + ); + }); }); describe('toBytes()', () => { @@ -195,11 +236,121 @@ describe('Document', () => { }); describe('generateId()', () => { + // The vector `Document::generate_document_id_v1` pins in rs-dpp: contract + // [1; 32], owner [2; 32], type "note", entropy [7; 32], nonce 1. Every + // client derives this id on its own, so the layout is consensus. + const pinnedContractId = new Uint8Array(32).fill(1); + const pinnedOwnerId = new Uint8Array(32).fill(2); + const pinnedEntropy = new Uint8Array(32).fill(7); + const pinnedId = 'e574ae73396611a517691d1f89275b6e99642cb9c176ce8cf879b1665c50f15f'; + it('should generate id', () => { - const generatedId = wasm.Document.generateId('note', ownerId, dataContractId); + const generatedId = wasm.Document.generateId('note', ownerId, dataContractId, undefined, BigInt(1)); expect(Array.from(generatedId).length).to.equal(32); }); + + it('should reproduce the pinned nonce-derived id', () => { + const generatedId = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + BigInt(1), + ); + + expect(toHexString(generatedId)).to.equal(pinnedId); + }); + + it('should reproduce the pinned id at an explicit latest platform version', () => { + const generatedId = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + BigInt(1), + PlatformVersion.latest(), + ); + + expect(toHexString(generatedId)).to.equal(pinnedId); + }); + + it('should derive a different id for every nonce', () => { + const first = wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy, BigInt(1)); + const second = wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy, BigInt(2)); + + expect(toHexString(first)).to.not.equal(toHexString(second)); + }); + + it('should keep the entropy in the id', () => { + const first = wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy, BigInt(1)); + const second = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + new Uint8Array(32).fill(8), + BigInt(1), + ); + + expect(toHexString(first)).to.not.equal(toHexString(second)); + }); + + it('should require the nonce from protocol version 14', () => { + expect(() => wasm.Document.generateId('note', pinnedOwnerId, pinnedContractId, pinnedEntropy)) + .to.throw(/identityContractNonce/); + }); + + it('should ignore the nonce before protocol version 14', () => { + const withoutNonce = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + undefined, + 13, + ); + const withNonce = wasm.Document.generateId( + 'note', + pinnedOwnerId, + pinnedContractId, + pinnedEntropy, + BigInt(1), + new PlatformVersion(13), + ); + + expect(toHexString(withoutNonce)).to.equal(toHexString(withNonce)); + expect(toHexString(withoutNonce)).to.not.equal(pinnedId); + }); + }); + + describe('setIdForCreation()', () => { + it('should give the document the id its create transition will carry', () => { + const documentInstance = createDocument({ id, entropy: fixedEntropy }); + + documentInstance.setIdForCreation(BigInt(3)); + + const expected = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + BigInt(3), + ); + expect(documentInstance.id.toBytes()).to.deep.equal(expected); + expect(documentInstance.id.toBase58()).to.not.equal(id); + }); + + it('should throw for a document without entropy', () => { + const dataContract = wasm.DataContract.fromJSON(dataContractValue, false); + const documentInstance = wasm.Document.fromBytes( + fromHexString(documentBytes), + dataContract, + 'note', + new PlatformVersion(1), + ); + + expect(() => documentInstance.setIdForCreation(BigInt(1))).to.throw(/entropy/); + }); }); describe('id', () => { diff --git a/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts index f740eae14b5..807b3f4663e 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentsTransitions.spec.ts @@ -101,6 +101,81 @@ describe('DocumentsTransitions', () => { expect(documentInstance).to.be.an.instanceof(wasm.Document); expect(createTransition).to.be.an.instanceof(wasm.DocumentCreateTransition); }); + + it('should derive the id from the entropy and the nonce and mirror it onto the document', () => { + // the document is built with an id that is not the one its create + // transition must carry: the constructor replaces it + const documentInstance = createDocument(); + expect(documentInstance.id.toBase58()).to.equal(id); + + const createTransition = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(7), + }); + + const derived = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + documentInstance.entropy, + BigInt(7), + ); + + expect(createTransition.base.id.toBytes()).to.deep.equal(derived); + expect(documentInstance.id.toBytes()).to.deep.equal(derived); + expect(documentInstance.id.toBase58()).to.not.equal(id); + }); + + it('should derive a different id for another nonce', () => { + const documentInstance = createDocument(); + const { entropy } = documentInstance; + + const first = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(1), + }); + const second = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(2), + }); + + expect(first.entropy).to.deep.equal(entropy); + expect(second.entropy).to.deep.equal(entropy); + expect(first.base.id.toBase58()).to.not.equal(second.base.id.toBase58()); + // the document follows the transition it was last built into + expect(documentInstance.id.toBase58()).to.equal(second.base.id.toBase58()); + }); + + it('should keep the entropy-only id before protocol version 14', () => { + const documentInstance = new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + revision: BigInt(revision), + }); + const placeholder = documentInstance.id.toBase58(); + + const createTransition = new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(1), + platformVersion: 13, + }); + + expect(createTransition.base.id.toBase58()).to.equal(placeholder); + expect(documentInstance.id.toBase58()).to.equal(placeholder); + }); + + it('should refuse a document without entropy', () => { + // a document read back from Platform carries none: it exists already + const documentInstance = createDocument(); + documentInstance.entropy = undefined; + + expect(() => new wasm.DocumentCreateTransition({ + document: documentInstance, + identityContractNonce: BigInt(1), + })).to.throw(/entropy/); + }); }); describe('toDocumentTransition()', () => { diff --git a/packages/wasm-sdk/src/state_transitions/document.rs b/packages/wasm-sdk/src/state_transitions/document.rs index 97bab5102fb..28abdff81ed 100644 --- a/packages/wasm-sdk/src/state_transitions/document.rs +++ b/packages/wasm-sdk/src/state_transitions/document.rs @@ -7,10 +7,9 @@ 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, DocumentV0Getters, DocumentV0Setters}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::IdentityPublicKey; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Identifier; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; use dash_sdk::platform::documents::transitions::DocumentDeleteTransitionBuilder; @@ -28,8 +27,8 @@ 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_mut, try_from_options_optional, try_from_options_with, + try_to_string, try_to_u64, IntoWasm, }; use wasm_dpp2::IdentitySignerWasm; @@ -229,13 +228,13 @@ impl WasmSdk { // that document too, so code that keeps using it (to replace, // transfer or delete what it just created) addresses the document // Platform stored. Best effort: the returned document is the - // authoritative one. - if let Ok(caller_document) = Reflect::get(&options, &JsValue::from_str("document")) { - let _ = Reflect::set( - &caller_document, - &JsValue::from_str("id"), - &JsValue::from_str(&confirmed_document.id().to_string(Encoding::Base58)), - ); + // authoritative one, and a caller that freed its document meanwhile + // is skipped. (A document still borrowed elsewhere would throw here + // rather than no-op; nothing re-enters wasm during the await.) + if let Ok(mut caller_document) = + try_from_options_mut::(&options, "document", "Document") + { + caller_document.inner_mut().set_id(confirmed_document.id()); } Ok(DocumentWasm::new( From 4c5bcdd114a9722c57782c325eded262f9c29cfe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 21 Sep 2026 09:17:06 +0700 Subject: [PATCH 2/2] fix(wasm-dpp2): nonce fixes a Document's id, and the create transition reads its options before borrowing the document Review fixes for #4868. - `new Document({ id, identityContractNonce })` derived nothing from the nonce when an explicit id was given, so a caller could reference that id from another document in the batch while the create transition carried the derived one. The nonce now fixes the id; an explicit id alongside it must equal the derived one or the constructor throws. - `DocumentCreateTransition`'s constructor now reads every other option before taking the mutable borrow of the document, so a JS getter on the options bag re-entering the same `Document` can no longer trip wasm-bindgen's recursive-borrow runtime error. - The book scopes "read the id from the transition" to the Rust path and says wasm-dpp2 writes the final id back onto `document`. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/documents.md | 4 +- packages/wasm-dpp2/README.md | 5 +- .../src/data_contract/document/model.rs | 48 ++++++++++++++----- .../batch/document_transitions/create.rs | 20 +++++--- .../wasm-dpp2/tests/unit/Document.spec.ts | 36 ++++++++++++++ 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 1276916b001..c56d6a8cd78 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -99,9 +99,9 @@ From protocol version 14 a reference to a document ID means that one document or The ID of a new document only exists once the nonce of its create transition is assigned, and it changes if the transition is rebuilt with another nonce: - The ID a `Document` carries before its create transition is built (for example the one `create_document_from_data` gives it) is a **placeholder**. `DocumentCreateTransitionV0::from_document` replaces it with the derived ID, so every transition built through dpp carries the right one. -- Read the ID from the transition, or from the confirmed document `put_to_platform_and_wait_for_response` returns, not from the document you passed in. +- On the Rust path (rs-sdk, or `from_document` directly) the `Document` you passed in keeps its placeholder: read the ID from the transition, or from the confirmed document `put_to_platform_and_wait_for_response` returns. - To know IDs up front (a chain of documents that reference each other), assign the nonces first: nonces may be used out of order within a window of 24. -- JavaScript gets the same through `wasm-dpp2`: `new DocumentCreateTransition({ document, identityContractNonce })` derives the ID for the network's protocol version (`platformVersion` option, latest by default), so the transition carries the right one and `document.id` is updated to match. `Document.generateId(type, owner, contract, entropy, identityContractNonce)` and `document.setIdForCreation(identityContractNonce)` give the ID before the transition exists, and `new Document({ ..., identityContractNonce })` derives it at construction. A `Document` built without a nonce carries the entropy-only placeholder. No app needs to reimplement the hash. +- JavaScript gets the same through `wasm-dpp2`, with one difference: `new DocumentCreateTransition({ document, identityContractNonce })` derives the ID for the network's protocol version (`platformVersion` option, latest by default) and writes it both onto the transition and back onto `document`, so after construction `document.id` is the final ID and may be read from there. `Document.generateId(type, owner, contract, entropy, identityContractNonce)` and `document.setIdForCreation(identityContractNonce)` give the ID before the transition exists, and `new Document({ ..., identityContractNonce })` derives it at construction (an explicit `id` passed alongside the nonce must equal the derived one). A `Document` built without a nonce carries the entropy-only placeholder until it is passed to `DocumentCreateTransition`. No app needs to reimplement the hash. ## The Accessor Traits diff --git a/packages/wasm-dpp2/README.md b/packages/wasm-dpp2/README.md index 95762b77ae1..4d9aa64fa0a 100644 --- a/packages/wasm-dpp2/README.md +++ b/packages/wasm-dpp2/README.md @@ -43,5 +43,6 @@ const ready = new Document({ properties, documentTypeName, dataContractId, owner Each of these takes an optional `platformVersion` (latest by default); before protocol version 14 the derivation ignores the nonce. Whatever id a `Document` carried before it is passed to `DocumentCreateTransition` is replaced: the -transition can only carry the id consensus recomputes. No app needs to -reimplement the hash. +transition can only carry the id consensus recomputes. For the same reason +`new Document({...})` refuses an explicit `id` that disagrees with the one its +`identityContractNonce` derives. No app needs to reimplement the hash. diff --git a/packages/wasm-dpp2/src/data_contract/document/model.rs b/packages/wasm-dpp2/src/data_contract/document/model.rs index 44ba47361b4..db3a5ad200b 100644 --- a/packages/wasm-dpp2/src/data_contract/document/model.rs +++ b/packages/wasm-dpp2/src/data_contract/document/model.rs @@ -20,7 +20,7 @@ use dpp::document::serialization_traits::{ // are imported inline at the call sites. use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; use dpp::identifier::Identifier; -use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; +use dpp::platform_value::string_encoding::Encoding::{Base58, Base64, Hex}; use dpp::platform_value::string_encoding::encode; use dpp::platform_value::{Value, ValueMapHelper}; use dpp::prelude::IdentityNonce; @@ -50,8 +50,11 @@ export interface DocumentOptions { revision?: bigint; /** * Document ID. Derived when not provided (see `identityContractNonce`). - * Whatever is given here is replaced by `new DocumentCreateTransition(...)`, - * which can only carry the id consensus recomputes. + * Together with `identityContractNonce` it must equal the derived id, or + * the constructor throws: the nonce fixes the id, and a different explicit + * one could only be referenced, never created. Whatever is given here is + * replaced by `new DocumentCreateTransition(...)`, which can only carry the + * id consensus recomputes. */ id?: IdentifierLike; /** Entropy bytes (32 bytes, auto-generated if not provided) */ @@ -282,15 +285,36 @@ impl DocumentWasm { )?; let doc_id: Identifier = match (id, identity_contract_nonce) { - (Some(id), _) => id.into(), - (None, Some(identity_contract_nonce)) => Document::generate_document_id( - &data_contract_id, - &owner_id, - &document_type_name, - &entropy, - identity_contract_nonce, - &platform_version, - )?, + // The nonce fixes the id: it is the one the create transition + // will carry. An explicit id may only restate it; one that + // differs would let the caller reference (from another document + // in the batch, say) an id no transition ever creates. + (id, Some(identity_contract_nonce)) => { + let derived = Document::generate_document_id( + &data_contract_id, + &owner_id, + &document_type_name, + &entropy, + identity_contract_nonce, + &platform_version, + )?; + + if let Some(id) = id { + let id: Identifier = id.into(); + if id != derived { + return Err(WasmDppError::invalid_argument(format!( + "id {} does not match the id {} derived from the document's entropy \ + and identityContractNonce {}: pass one or the other", + id.to_string(Base58), + derived.to_string(Base58), + identity_contract_nonce, + ))); + } + } + + derived + } + (Some(id), None) => id.into(), // Without the nonce of the create transition the id can only be // the entropy-only one, which from protocol version 14 is a // placeholder: `DocumentCreateTransition` replaces it. diff --git a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs index b554986c617..8aa514d7dfb 100644 --- a/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs +++ b/packages/wasm-dpp2/src/state_transitions/batch/document_transitions/create.rs @@ -83,13 +83,6 @@ impl DocumentCreateTransitionWasm { .unwrap_or_default() .into(); - // The id a document carries before its nonce is known is a - // placeholder: derive the one consensus will recompute, on the - // caller's own object so `document.id` matches `transition.base.id`, - // as `DocumentCreateTransitionV0::from_document` does in dpp. - let mut document = try_from_options_mut::(&options, "document", "Document")?; - document.set_id_for_creation(identity_contract_nonce, &platform_version)?; - let prefunded_voting_balance: Option = try_from_options_optional(&options, "prefundedVotingBalance")?; @@ -99,6 +92,19 @@ impl DocumentCreateTransitionWasm { let action_fee_agreement: Option = try_from_options_optional(&options, "actionFeeAgreement")?; + // The id a document carries before its nonce is known is a + // placeholder: derive the one consensus will recompute, on the + // caller's own object so `document.id` matches `transition.base.id`, + // as `DocumentCreateTransitionV0::from_document` does in dpp. + // + // Every other property is read above, before this borrow: a JS + // getter on the options bag could re-enter the same `Document`, and + // wasm-bindgen reports a second borrow of a mutably borrowed object + // as an unrecoverable runtime error, not as a result. From here on + // nothing calls back into JavaScript. + let mut document = try_from_options_mut::(&options, "document", "Document")?; + document.set_id_for_creation(identity_contract_nonce, &platform_version)?; + let rs_create_transition = generate_create_transition( &document, identity_contract_nonce, diff --git a/packages/wasm-dpp2/tests/unit/Document.spec.ts b/packages/wasm-dpp2/tests/unit/Document.spec.ts index bf4807e6fbc..135d94f6a4c 100644 --- a/packages/wasm-dpp2/tests/unit/Document.spec.ts +++ b/packages/wasm-dpp2/tests/unit/Document.spec.ts @@ -117,6 +117,42 @@ describe('Document', () => { createDocument({ entropy: fixedEntropy }).id.toBytes(), ); }); + + it('should accept an explicit id that equals the one derived from the nonce', () => { + const derived = wasm.Document.generateId( + documentTypeName, + ownerId, + dataContractId, + fixedEntropy, + BigInt(5), + ); + + const documentInstance = new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + entropy: fixedEntropy, + id: derived, + identityContractNonce: BigInt(5), + }); + + expect(documentInstance.id.toBytes()).to.deep.equal(derived); + }); + + it('should reject an explicit id that differs from the one derived from the nonce', () => { + // the nonce fixes the id; an explicit id that disagrees could be + // referenced by another document but never created + expect(() => new wasm.Document({ + properties: document, + documentTypeName, + dataContractId, + ownerId, + entropy: fixedEntropy, + id, + identityContractNonce: BigInt(5), + })).to.throw(/does not match the id .* derived .* identityContractNonce 5/); + }); }); describe('toBytes()', () => {