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
16 changes: 16 additions & 0 deletions packages/js-evo-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,22 @@ try {
}
```

To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema, and reads the document's owner for `$ownerId`:

```ts
contract.documentTypePropertyConstraints('offer');
// [{ name: 'discountBelowPrice', rule: { lessThan: ['discount', 'price'] },
// reads: [{ path: 'discount', kind: 'value' }, { path: 'price', kind: 'value' }],
// readsOwner: false }, ...]

const broken = contract.checkDocumentPropertyConstraints(document);
if (broken) {
// { rule: 'discountBelowPrice', violation: 'NotMet', message: 'it does not hold' }
}
```

Rules come back in name order, the order consensus checks them in; `contract.documentPropertyConstraints` maps every document type that declares rules to its list. The `PropertyConstraintCondition`, `PropertyConstraintExpression` and `PropertyConstraintEqualityOperand` types spell out the rule grammar, and `violation` is one of `NotMet`, `Overflow`, `DivisionByZero`, `NegativeExponent` or `NotAnInteger`, the reason consensus would report.

## 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. For a `permanentDocument` join property a missing referenced document fails verification outright, since such a reference cannot dangle. For a `deletableDocument` join property a referenced document that was deleted since is proven absent: it has no entry in `outerDocuments` (so match the two halves by id, not by position) and its id is listed in `missingOuterIds`, in first-appearance order. The node still cannot pass an existing document off as deleted.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
//! `propertyConstraints` rules: the conditions a document type holds every
//! created or replaced document's properties to, from protocol version 14
//! onward.
//!
//! A rule is a condition: a comparison of integer expressions, a membership
//! test (`in`), a comparison of a string or an identifier property (or
//! `$ownerId`, the document's owner) with constants or with another property
//! of its kind, a presence test (`present`, `absent`), or `anyOf`, `allOf` or
//! `not` over conditions. Consensus evaluates every rule on each create and
//! replace, and the rules reading `$ownerId` on each transfer and purchase,
//! refusing a broken one with `DocumentPropertyConstraintViolatedError`
//! (basic code 10422). What this module adds is *discovery* ("which rules does
//! this document type declare, and what do they read?") and a *pre-check*
//! that evaluates a document against them with the very code consensus runs,
//! so an app can find a broken rule before paying for a refused transition.

use crate::error::{WasmDppError, WasmDppResult};
use dpp::consensus::basic::document::PropertyConstraintViolation;
use dpp::data_contract::document_type::DocumentTypeRef;
use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters};
use dpp::data_contract::document_type::property_constraints::PropertyRead;
use dpp::document::{Document, DocumentV0Getters};
use dpp::platform_value::Value;
use js_sys::{Array, BigInt, Object, Reflect};
use wasm_bindgen::JsValue;
use wasm_bindgen::prelude::wasm_bindgen;

#[wasm_bindgen(typescript_custom_section)]
const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#"
/**
* An integer expression of a `propertyConstraints` rule.
*
* - a number: an integer value, a `bigint` past `Number.MAX_SAFE_INTEGER`
* (rules report such a literal as a `bigint`, exactly);
* - a string: the dotted path of an integer or boolean property, whose value
* it takes (a boolean reads as 1 for true and 0 for false), 0 when the
* document leaves the property out;
* - `ifAbsent`: a property path and the integer it takes when left out;
* - `add` and `multiply` over two or more operands, `subtract`, `divide`,
* `modulo` and `power` over exactly two. Arithmetic is exact over 128-bit
* integers; `divide` and `modulo` are Euclidean.
*/
export type PropertyConstraintExpression =
| number
| bigint
| string
| { ifAbsent: [path: string, value: number | bigint] }
| { add: PropertyConstraintExpression[] }
| { multiply: PropertyConstraintExpression[] }
| { subtract: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { divide: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { modulo: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { power: [PropertyConstraintExpression, PropertyConstraintExpression] };

/**
* One side of a comparison of strings or identifiers.
*
* - a string: the dotted path of a string or an identifier property, or
* `"$ownerId"`, the document's owner, an identifier;
* - `const`: a string constant, or a base58 identifier beside an identifier
* property or `$ownerId`;
* - `ifAbsent`: a string property with the string it takes when left out.
*
* A property the document leaves out without a default equals nothing.
*/
export type PropertyConstraintEqualityOperand =
| string
| { const: string }
| { ifAbsent: [path: string, value: string] };

/**
* A `propertyConstraints` rule, or a condition inside one.
*
* - a comparison of two integer expressions; `equal` and `notEqual` also
* compare strings or identifiers, which are never ordered;
* - `in`: an integer expression and two or more distinct integers, or a
* string or identifier property (or `$ownerId`) and two or more distinct
* strings or base58 identifiers;
* - `present` / `absent`: whether the document holds a property of any type;
* - `anyOf` / `allOf` over two or more conditions, `not` over one. Conditions
* are checked in order and no further than the outcome needs.
*/
export type PropertyConstraintCondition =
| { equal: [PropertyConstraintExpression, PropertyConstraintExpression] | [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] }
| { notEqual: [PropertyConstraintExpression, PropertyConstraintExpression] | [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] }
| { lessThan: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { lessThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] }
| { in: [PropertyConstraintExpression, Array<number | bigint>] | [string | { ifAbsent: [path: string, value: string] }, string[]] }
| { present: string }
| { absent: string }
| { anyOf: PropertyConstraintCondition[] }
| { allOf: PropertyConstraintCondition[] }
| { not: PropertyConstraintCondition };

/**
* How a rule reads a property: `value` as an integer operand, `presence` in
* `present` or `absent`, `text` compared with strings, `identifier` compared
* with identifiers.
*/
export type PropertyConstraintReadKind = 'value' | 'presence' | 'text' | 'identifier';

/**
* A single `propertyConstraints` rule of a document type.
*/
export type DocumentPropertyConstraint = {
/** The rule's name, its key in `propertyConstraints`. Rules are checked in name order. */
name: string;
/** The rule as the schema declares it. */
rule: PropertyConstraintCondition;
/** Every property the rule reads, in declared order; `$ownerId` is no property and is not listed. */
reads: Array<{ path: string; kind: PropertyConstraintReadKind }>;
/** Whether the rule reads `$ownerId`: then a transfer or a purchase is judged against it too. */
readsOwner: boolean;
};

/**
* Why a document breaks a rule, the `violation` consensus reports in
* `DocumentPropertyConstraintViolatedError` (code 10422): `NotMet` when the
* rule evaluates to false, or the fault met evaluating it.
*/
export type PropertyConstraintViolationKind =
| 'NotMet'
| 'Overflow'
| 'DivisionByZero'
| 'NegativeExponent'
| 'NotAnInteger';

/**
* The first rule a document breaks, as consensus would report it.
*/
export type DocumentPropertyConstraintViolation = {
/** The broken rule's name. */
rule: string;
violation: PropertyConstraintViolationKind;
/** A readable reason, as in the consensus error's message. */
message: string;
};
"#;

#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(typescript_type = "Array<DocumentPropertyConstraint>")]
pub type DocumentPropertyConstraintArrayJs;

#[wasm_bindgen(typescript_type = "Map<string, Array<DocumentPropertyConstraint>>")]
pub type DocumentPropertyConstraintMapJs;

#[wasm_bindgen(typescript_type = "DocumentPropertyConstraintViolation | undefined")]
pub type DocumentPropertyConstraintViolationJs;
}

/// `Reflect::set` with the collection-getter error convention the other
/// document type accessors use.
fn set_field(target: &Object, key: &str, value: &JsValue, rule: &str) -> WasmDppResult<()> {
Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| {
WasmDppError::generic(format!(
"unable to serialize the `{key}` field of the propertyConstraints rule '{rule}'"
))
})?;
Ok(())
}

/// The name a read kind goes by in `PropertyConstraintReadKind`.
fn read_kind_name(read: PropertyRead) -> &'static str {
match read {
PropertyRead::Value => "value",
PropertyRead::Presence => "presence",
PropertyRead::Text => "text",
PropertyRead::Identifier => "identifier",
}
}

/// The name a violation goes by in `PropertyConstraintViolationKind`, the
/// variant's own.
fn violation_name(violation: PropertyConstraintViolation) -> &'static str {
match violation {
PropertyConstraintViolation::NotMet => "NotMet",
PropertyConstraintViolation::Overflow => "Overflow",
PropertyConstraintViolation::DivisionByZero => "DivisionByZero",
PropertyConstraintViolation::NegativeExponent => "NegativeExponent",
PropertyConstraintViolation::NotAnInteger => "NotAnInteger",
}
}

/// `Number.MAX_SAFE_INTEGER`, the largest integer a JS `number` holds exactly.
const MAX_SAFE_INTEGER: i128 = (1 << 53) - 1;

/// An integer literal of a rule: a `number` while it is exact in JavaScript,
/// a `bigint` past that.
fn integer_to_js(integer: i128) -> JsValue {
if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&integer) {
JsValue::from_f64(integer as f64)
} else {
BigInt::from(integer).into()
}
}

/// A declared rule as JS, the JSON it was declared as. Not through
/// `serde_json`: a rule may compare with any 64-bit literal, and the JSON
/// conversion throws on one past `Number.MAX_SAFE_INTEGER`, which would hide
/// every rule of the type.
fn rule_to_js(value: &Value, rule: &str) -> WasmDppResult<JsValue> {
Ok(match value {
Value::Text(text) => JsValue::from_str(text),
Value::Bool(flag) => JsValue::from_bool(*flag),
Value::Null => JsValue::NULL,
Value::Float(number) => JsValue::from_f64(*number),
Value::U8(integer) => integer_to_js((*integer).into()),
Value::U16(integer) => integer_to_js((*integer).into()),
Value::U32(integer) => integer_to_js((*integer).into()),
Value::U64(integer) => integer_to_js((*integer).into()),
Value::I8(integer) => integer_to_js((*integer).into()),
Value::I16(integer) => integer_to_js((*integer).into()),
Value::I32(integer) => integer_to_js((*integer).into()),
Value::I64(integer) => integer_to_js((*integer).into()),
Value::I128(integer) => integer_to_js(*integer),
Value::U128(integer) => match i128::try_from(*integer) {
Ok(integer) => integer_to_js(integer),
Err(_) => BigInt::from(*integer).into(),
},
Value::Array(items) => {
let array = Array::new();
for item in items {
array.push(&rule_to_js(item, rule)?);
}
array.into()
}
Value::Map(entries) => {
let object = Object::new();
for (key, entry) in entries {
let key = key.as_text().ok_or_else(|| {
WasmDppError::generic(format!(
"the propertyConstraints rule '{rule}' has a key that is not a string"
))
})?;
set_field(&object, key, &rule_to_js(entry, rule)?, rule)?;
}
object.into()
}
other => {
return Err(WasmDppError::generic(format!(
"the propertyConstraints rule '{rule}' holds {other}, which is not JSON"
)));
}
})
}

/// Collect every `propertyConstraints` rule of one document type, in name
/// order, the order consensus checks them in.
///
/// The parsed rules give the name, the reads and whether the owner is read;
/// the rule itself is the schema's declaration, which is what an app wrote,
/// with integer literals past `Number.MAX_SAFE_INTEGER` as `bigint`.
pub(crate) fn property_constraints_for_document_type(
document_type: DocumentTypeRef<'_>,
) -> WasmDppResult<Array> {
let rules = Array::new();
let declarations = document_type
.schema()
.get_optional_value("propertyConstraints")
.ok()
.flatten();

for (name, constraint) in document_type.property_constraints() {
let object = Object::new();
set_field(&object, "name", &JsValue::from_str(name), name)?;

let declared = declarations
.and_then(|declarations| declarations.get_optional_value(name).ok().flatten())
.ok_or_else(|| {
WasmDppError::generic(format!(
"the propertyConstraints rule '{name}' is missing from the document type's \
schema"
))
})?;
set_field(&object, "rule", &rule_to_js(declared, name)?, name)?;

let reads = Array::new();
for (path, read) in constraint.property_reads() {
let entry = Object::new();
set_field(&entry, "path", &JsValue::from_str(path), name)?;
set_field(
&entry,
"kind",
&JsValue::from_str(read_kind_name(read)),
name,
)?;
reads.push(&entry);
}
set_field(&object, "reads", &reads, name)?;
set_field(
&object,
"readsOwner",
&JsValue::from_bool(constraint.reads_owner()),
name,
)?;
rules.push(&object);
}

Ok(rules)
}

/// The first rule of `document_type`'s `propertyConstraints` that `document`
/// breaks, in name order, as consensus judges a create or replace: its
/// properties, and its owner for `$ownerId`. `undefined` when it meets them
/// all.
pub(crate) fn check_property_constraints(
document_type: DocumentTypeRef<'_>,
document: &Document,
) -> WasmDppResult<JsValue> {
let constraints = document_type.property_constraints();
if constraints.is_empty() {
return Ok(JsValue::UNDEFINED);
}
let data = Value::from(document.properties().clone());
for (name, constraint) in constraints {
if let Some(violation) = constraint.violation(&data, Some(document.owner_id())) {
let object = Object::new();
set_field(&object, "rule", &JsValue::from_str(name), name)?;
set_field(
&object,
"violation",
&JsValue::from_str(violation_name(violation)),
name,
)?;
set_field(
&object,
"message",
&JsValue::from_str(&violation.to_string()),
name,
)?;
return Ok(object.into());
}
}
Ok(JsValue::UNDEFINED)
}
5 changes: 5 additions & 0 deletions packages/wasm-dpp2/src/data_contract/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod document;
pub mod document_type_distinct_from;
pub mod document_type_encryption;
pub mod document_type_immutability;
pub mod document_type_property_constraints;
pub mod document_type_reference;
pub mod document_type_typed_arrays;
pub mod model;
Expand All @@ -19,6 +20,10 @@ pub use document_type_encryption::{
pub use document_type_immutability::{
DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs,
};
pub use document_type_property_constraints::{
DocumentPropertyConstraintArrayJs, DocumentPropertyConstraintMapJs,
DocumentPropertyConstraintViolationJs,
};
pub use document_type_reference::{
DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs,
};
Expand Down
Loading
Loading