Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/js-evo-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
72 changes: 72 additions & 0 deletions packages/wasm-dpp2/src/consensus_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
match code {
40128 => Some(Self::DocumentImmutablePropertyChanged),
_ => None,
}
}
}

#[wasm_bindgen(js_name = "ConsensusError")]
pub struct ConsensusErrorWasm(ConsensusError);

Expand Down Expand Up @@ -95,6 +128,13 @@ impl ConsensusErrorWasm {
pub fn document_reference_error_code(&self) -> Option<DocumentReferenceErrorCodeWasm> {
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> {
DocumentImmutabilityErrorCodeWasm::from_code(self.0.code())
}
}

impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError);
Expand All @@ -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.
///
Expand Down
101 changes: 101 additions & 0 deletions packages/wasm-dpp2/src/data_contract/document_type_immutability.rs
Original file line number Diff line number Diff line change
@@ -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<string, DocumentTypeImmutableProperties>")]
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<String>) -> 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<Object> {
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)
}
4 changes: 4 additions & 0 deletions packages/wasm-dpp2/src/data_contract/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down
59 changes: 59 additions & 0 deletions packages/wasm-dpp2/src/data_contract/model.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand All @@ -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;
Expand Down Expand Up @@ -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<DocumentTypeImmutablePropertiesJs> {
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<DocumentTypeImmutablePropertiesMapJs> {
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 {
Expand Down
Loading
Loading