diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 62ad8973d6b..cd00e00a11f 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) +- [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) - [Contributing](#contributing) @@ -216,6 +217,38 @@ try { } ``` +## 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: + +```ts +const contract = await sdk.contracts.fetch(contractId); + +contract.documentTypeImmutableProperties('post'); +// { immutable: ['author', 'mood'], immutableAllowSetting: ['mood'] } +// Both arrays hold top-level property names, sorted. Listing an object +// property freezes it whole, nested values included. + +// Every document type that freezes at least one property. +contract.documentImmutableProperties; +``` + +The lists are only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports empty lists even when its raw schema carries the keywords. + +A replace that changes, adds or removes a frozen property is rejected, and the consensus code reaches JS as `error.code`: + +```ts +import { DocumentImmutabilityErrorCode } from '@dashevo/evo-sdk'; + +try { + await sdk.documents.replace({ document, identityKey, signer }); +} catch (e) { + if (e.code === DocumentImmutabilityErrorCode.DocumentImmutablePropertyChanged) { + // the replace touched a property the document type freezes (code 40128) + } +} +``` + ## Chained queries (provable semi-join) A `refersTo: permanentDocument` declaration also lights up the read side: a **chained query** answers `SELECT * FROM post WHERE $id IN (SELECT postId FROM like WHERE $ownerId = me)` in one verified round trip. The node returns the inner indexOnly page and the referenced documents under ONE merged proof — a single quorum-signed state root by construction — and the SDK re-derives the outer query itself and checks it against the *proven* inner values — the node cannot substitute, omit, or inject joined documents (a missing referenced document fails verification outright, since `permanentDocument` references cannot dangle). diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index bf88b894bc4..2f80bd8ddc4 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -62,6 +62,39 @@ impl DocumentReferenceErrorCodeWasm { } } +/// Consensus error codes emitted by the immutable-property check on document +/// replaces (`immutable` / `immutableAllowSetting`, protocol version 14+). +/// +/// Branch on an error's `code` against this instead of matching its message: +/// +/// ```js +/// try { +/// await sdk.documents.replace({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentImmutabilityErrorCode.DocumentImmutablePropertyChanged) { +/// // the replace touched a property the document type freezes +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentImmutabilityErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentImmutabilityErrorCodeWasm { + /// The replace changed, added or removed a property the document type + /// lists under `immutable`, and the change was not the one first-time + /// set `immutableAllowSetting` permits. + DocumentImmutablePropertyChanged = 40128, +} + +impl DocumentImmutabilityErrorCodeWasm { + /// The immutability error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 40128 => Some(Self::DocumentImmutablePropertyChanged), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -95,6 +128,13 @@ impl ConsensusErrorWasm { pub fn document_reference_error_code(&self) -> Option { DocumentReferenceErrorCodeWasm::from_code(self.0.code()) } + + /// The immutable-property error this is, or `undefined` when it is not + /// code 40128. + #[wasm_bindgen(getter = "documentImmutabilityErrorCode")] + pub fn document_immutability_error_code(&self) -> Option { + DocumentImmutabilityErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -116,6 +156,38 @@ mod tests { Identifier::from([1u8; 32]) } + /// Built from the real DPP error rather than a code literal, for the + /// same reason as `cases()` below: the code comes back through + /// [`ErrorWithCode`], which is the source of truth the JS enum mirrors. + #[test] + fn immutability_error_code_mirrors_the_dpp_error() { + use dpp::consensus::state::document::document_immutable_property_changed_error::DocumentImmutablePropertyChangedError; + + let error: ConsensusError = StateError::DocumentImmutablePropertyChangedError( + DocumentImmutablePropertyChangedError::new( + id(), + "post".to_string(), + "author".to_string(), + ), + ) + .into(); + + assert_eq!( + DocumentImmutabilityErrorCodeWasm::from_code(error.code()), + Some(DocumentImmutabilityErrorCodeWasm::DocumentImmutablePropertyChanged) + ); + assert_eq!( + DocumentImmutabilityErrorCodeWasm::DocumentImmutablePropertyChanged as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_immutability_error_code(), + Some(DocumentImmutabilityErrorCodeWasm::DocumentImmutablePropertyChanged) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentImmutabilityErrorCodeWasm::from_code(40127), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// diff --git a/packages/wasm-dpp2/src/data_contract/document_type_immutability.rs b/packages/wasm-dpp2/src/data_contract/document_type_immutability.rs new file mode 100644 index 00000000000..e13a350438d --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_immutability.rs @@ -0,0 +1,101 @@ +//! `immutable` / `immutableAllowSetting` declarations: the per-property +//! immutability a mutable document type carries from protocol version 14 +//! onward. +//! +//! `immutable` lists the top-level properties frozen at document creation, +//! and `immutableAllowSetting` the subset of them a replace may still set +//! while the stored document has no value for them (frozen from then on). +//! Consensus enforces both on every replace (code 40128). What this module +//! adds is the ability to *discover* the declarations, "which properties of +//! this document type can never change, and which may still be set once?", +//! without hand-parsing the contract's raw JSON schema. + +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use js_sys::{Array, Object, Reflect}; +use std::collections::BTreeSet; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_TYPE_IMMUTABLE_PROPERTIES_TS: &'static str = r#" +/** + * The immutability declarations of one document type. + * + * Mirrors the `immutable` and `immutableAllowSetting` keywords of the v3 + * document meta-schema, which are active from protocol version 14. The + * field names are the schema keywords' own, so what `contract.toJSON()` + * shows and what these accessors return line up key for key. Both arrays + * are sorted by property name and hold top-level property names only: + * listing an object property freezes it whole, nested values included. + */ +export type DocumentTypeImmutableProperties = { + /** + * Top-level properties frozen at document creation. A replace that + * changes, adds or removes any of them is rejected with consensus code + * 40128 (`DocumentImmutabilityErrorCode.DocumentImmutablePropertyChanged`), + * except for the one transition `immutableAllowSetting` permits. + */ + immutable: string[]; + /** + * The subset of `immutable` a replace may still set while the stored + * document has no value for it. Once present the property is frozen like + * the rest. Always a subset of `immutable`. + */ + immutableAllowSetting: string[]; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "DocumentTypeImmutableProperties")] + pub type DocumentTypeImmutablePropertiesJs; + + #[wasm_bindgen(typescript_type = "Map")] + pub type DocumentTypeImmutablePropertiesMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field( + target: &Object, + key: &str, + value: &JsValue, + document_type_name: &str, +) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the immutability declarations of document \ + type '{document_type_name}'" + )) + })?; + Ok(()) +} + +fn names_to_array(names: &BTreeSet) -> Array { + names.iter().map(|name| JsValue::from_str(name)).collect() +} + +/// Build the `{ immutable, immutableAllowSetting }` object for one document +/// type. Both arrays come out in name order, which is the order the sets +/// keep and the order consensus reports the first offending property in. +pub(crate) fn immutable_properties_for_document_type( + document_type: DocumentTypeRef<'_>, + document_type_name: &str, +) -> WasmDppResult { + let object = Object::new(); + set_field( + &object, + "immutable", + &names_to_array(document_type.immutable_fields()).into(), + document_type_name, + )?; + set_field( + &object, + "immutableAllowSetting", + &names_to_array(document_type.immutable_fields_allow_setting()).into(), + document_type_name, + )?; + Ok(object) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index a3050527a67..d96e1a7d3e9 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -1,11 +1,15 @@ pub mod contract_bounds; pub mod document; +pub mod document_type_immutability; pub mod document_type_reference; pub mod model; pub mod transitions; pub use contract_bounds::ContractBoundsWasm; pub use document::DocumentWasm; +pub use document_type_immutability::{ + DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, +}; pub use document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index a57b211b082..9aed1c6d56d 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,7 @@ +use crate::data_contract::document_type_immutability::{ + DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, + immutable_properties_for_document_type, +}; use crate::data_contract::document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, references_for_document_type, }; @@ -20,6 +24,7 @@ use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::errors::DataContractError; use dpp::data_contract::group::Group; use dpp::data_contract::schema::DataContractSchemaMethodsV0; @@ -663,6 +668,60 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + + /// The `immutable` / `immutableAllowSetting` declarations of one + /// document type: `{ immutable: string[], immutableAllowSetting: + /// string[] }`, both sorted by property name. + /// + /// Both arrays are empty when the document type declares nothing (the + /// normal case, and the only case for a type whose documents are not + /// mutable). Throws when the contract has no document type by that + /// name, so "no such type" and "nothing frozen" stay distinguishable. + /// + /// The keywords are only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports + /// empty lists, which is exactly what consensus enforced at that + /// version, while `toJSON()` still shows the raw keywords either way. + #[wasm_bindgen(js_name = "documentTypeImmutableProperties")] + pub fn document_type_immutable_properties( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let properties = + immutable_properties_for_document_type(document_type, document_type_name.as_str())?; + Ok(JsValue::from(properties).into()) + } + + /// Every document type that freezes at least one property, keyed by + /// document type name. + /// + /// Document types with an empty `immutable` list are omitted, so an + /// empty `Map` means "nothing in this contract is frozen per property". + #[wasm_bindgen(getter = "documentImmutableProperties")] + pub fn document_immutable_properties( + &self, + ) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + if document_type.immutable_fields().is_empty() { + continue; + } + let properties = immutable_properties_for_document_type(document_type.as_ref(), name)?; + map.set(&JsValue::from_str(name), &properties.into()); + } + + Ok(JsValue::from(map).into()) + } } impl DataContractWasm { diff --git a/packages/wasm-dpp2/tests/unit/DocumentTypeImmutableProperties.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentTypeImmutableProperties.spec.ts new file mode 100644 index 00000000000..b49633b7d50 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentTypeImmutableProperties.spec.ts @@ -0,0 +1,179 @@ +/** + * Verifies the `immutable` / `immutableAllowSetting` metadata surface + * introduced with protocol version 14. + * + * On a mutable document type, `immutable` lists top-level properties frozen + * at creation and `immutableAllowSetting` the subset a replace may still set + * while the stored document has no value for them. Consensus enforces both + * on every replace (code 40128). What the JS layer offers is *discovery* + * (which properties are frozen, and which may still be set once) plus a + * branchable error code for when a replace is rejected. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = '11111111111111111111111111111111'; + +/** + * A `post` whose author can never change and whose mood can be set once, + * next to a `plain` type declaring nothing. + */ +const schemas = { + post: { + type: 'object', + documentsMutable: true, + properties: { + author: { type: 'string', position: 0, maxLength: 63 }, + body: { type: 'string', position: 1, maxLength: 500 }, + mood: { type: 'string', position: 2, maxLength: 30 }, + }, + required: ['author', 'body'], + immutable: ['mood', 'author'], + immutableAllowSetting: ['mood'], + additionalProperties: false, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(platformVersion: number, fullValidation = true) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +type ImmutableProperties = { + immutable: string[]; + immutableAllowSetting: string[]; +}; + +describe('DataContract — immutable properties (v14)', () => { + describe('documentTypeImmutableProperties()', () => { + it('should report both lists, sorted by property name', () => { + const contract = buildContract(14); + + expect(contract.documentTypeImmutableProperties('post')).to.deep.equal({ + immutable: ['author', 'mood'], + immutableAllowSetting: ['mood'], + }); + }); + + it('should return empty lists for a document type declaring none', () => { + const contract = buildContract(14); + + expect(contract.documentTypeImmutableProperties('plain')).to.deep.equal({ + immutable: [], + immutableAllowSetting: [], + }); + }); + + /** + * Empty lists would conflate "no such type" with "nothing frozen", + * which is a difference a caller acting on the result needs. + */ + it('should throw for an unknown document type', () => { + const contract = buildContract(14); + + expect(() => contract.documentTypeImmutableProperties('doesNotExist')).to.throw(/not found/); + }); + + /** + * The keywords are only parsed from protocol version 14 onward. A + * contract deserialized against an earlier version reports nothing + * frozen even though its raw schema still carries them. + */ + it('should report nothing on a pre-v14 contract, while the raw schema keeps the keywords', () => { + const contract = buildContract(13, false); + + expect(contract.documentTypeImmutableProperties('post')).to.deep.equal({ + immutable: [], + immutableAllowSetting: [], + }); + + const rawSchemas = contract.schemas as Record< + string, + { immutable?: string[]; immutableAllowSetting?: string[] } + >; + expect(rawSchemas.post.immutable).to.deep.equal(['mood', 'author']); + expect(rawSchemas.post.immutableAllowSetting).to.deep.equal(['mood']); + }); + + /** + * The lists are consensus-validated at registration: an + * `immutableAllowSetting` entry outside `immutable`, an unknown + * property, or a list on a non-mutable type is a contract error, not + * something the accessor has to guard against. + */ + it('should refuse a contract whose allow-setting entry is not immutable', () => { + const build = () => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { + post: { ...schemas.post, immutableAllowSetting: ['body'] }, + }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect(build).to.throw(/not in `immutable`/); + }); + }); + + describe('documentImmutableProperties', () => { + it('should key declarations by document type and omit types freezing nothing', () => { + const contract = buildContract(14); + const map = contract.documentImmutableProperties as Map; + + expect([...map.keys()]).to.deep.equal(['post']); + expect(map.get('post')).to.deep.equal(contract.documentTypeImmutableProperties('post')); + }); + + it('should be empty for a contract freezing nothing at all', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { plain: schemas.plain }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect((contract.documentImmutableProperties as Map).size).to.equal(0); + }); + }); + + describe('DocumentImmutabilityErrorCode', () => { + /** + * The number a caller compares `WasmSdkError.code` against after a + * rejected replace. Renumbering it silently breaks every `switch` in + * the wild. + */ + it('should map the immutable-property error to its consensus code', () => { + expect(wasm.DocumentImmutabilityErrorCode.DocumentImmutablePropertyChanged).to.equal(40128); + }); + + it('should resolve the code back to its name', () => { + const codes = wasm.DocumentImmutabilityErrorCode as unknown as Record; + + expect(codes[40128]).to.equal('DocumentImmutablePropertyChanged'); + }); + }); +}); diff --git a/packages/wasm-sdk/tests/unit/data-contract.spec.ts b/packages/wasm-sdk/tests/unit/data-contract.spec.ts index da39901b9cf..9f87ad300f3 100644 --- a/packages/wasm-sdk/tests/unit/data-contract.spec.ts +++ b/packages/wasm-sdk/tests/unit/data-contract.spec.ts @@ -558,4 +558,28 @@ describe('DataContract', () => { expect(sdk.DocumentReferenceErrorCode.ReferencedKeyIdPropertyInvalid).to.equal(40125); }); }); + + /** + * Same arrangement for the `immutable` / `immutableAllowSetting` surface + * (protocol version 14): behaviour lives in wasm-dpp2's suite, this pins + * the re-export. + */ + describe('immutable properties metadata re-export', () => { + it('should expose the immutability accessors on DataContract', () => { + const contract = sdk.DataContract.fromJSON( + contractFixtureV1, + true, + PLATFORM_VERSION_CONTRACT_V1, + ); + + expect(contract.documentTypeImmutableProperties).to.be.a('function'); + expect(contract.documentImmutableProperties).to.be.instanceOf(Map); + + contract.free(); + }); + + it('should expose the immutable-property consensus error code', () => { + expect(sdk.DocumentImmutabilityErrorCode.DocumentImmutablePropertyChanged).to.equal(40128); + }); + }); });