diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 34068e6d753..c56d6a8cd78 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -99,8 +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`, 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/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..4d9aa64fa0a 100644 --- a/packages/wasm-dpp2/README.md +++ b/packages/wasm-dpp2/README.md @@ -11,3 +11,38 @@ 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. 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 525119515cb..db3a5ad200b 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::{ @@ -20,11 +20,13 @@ 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; 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,28 @@ 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`). + * 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) */ 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 +181,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 +254,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 +284,47 @@ impl DocumentWasm { Ok, )?; - let doc_id: Identifier = id.map_or_else( - || { - crate::utils::generate_document_id_v0( + let doc_id: Identifier = match (id, identity_contract_nonce) { + // 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, - ) - }, - |id| Ok(id.into()), - )?; + 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. + (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 +451,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 +841,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 +869,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 +948,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..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 @@ -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,16 @@ 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(); + let prefunded_voting_balance: Option = try_from_options_optional(&options, "prefundedVotingBalance")?; @@ -78,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/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..135d94f6a4c 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,83 @@ 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(), + ); + }); + + 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()', () => { @@ -195,11 +272,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(