From fab5826b851e6ae4dd59efa640e730bde2c8c060 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 27 Sep 2026 18:16:36 +0700 Subject: [PATCH 1/2] feat(sdk): propertyConstraints discovery and pre-check in the JS SDK wasm-dpp2's DataContract gains documentTypePropertyConstraints(name), the documentPropertyConstraints map getter, and checkDocumentPropertyConstraints(document): the first rule a Document breaks, evaluated with the Rust code consensus runs (properties plus the owner for $ownerId), or undefined. TypeScript types spell out the full rule grammar (comparisons, arithmetic, ifAbsent, in, const, $ownerId, present/absent, anyOf/allOf/not) and the violation kinds. Co-Authored-By: Claude Opus 5.5 --- packages/js-evo-sdk/README.md | 16 + .../document_type_property_constraints.rs | 274 ++++++++++++++++++ packages/wasm-dpp2/src/data_contract/mod.rs | 5 + packages/wasm-dpp2/src/data_contract/model.rs | 93 ++++++ .../unit/DocumentPropertyConstraints.spec.ts | 260 +++++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs create mode 100644 packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 9d3cf05b3c2..21db109a290 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -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. diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs new file mode 100644 index 00000000000..490d9d3f0a5 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -0,0 +1,274 @@ +//! `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 crate::serialization::conversions::platform_value_to_json; +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, 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 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 + | string + | { ifAbsent: [path: string, value: number] } + | { 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, number[]] | [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")] + pub type DocumentPropertyConstraintArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + 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", + } +} + +/// 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 +/// and what `toJSON()` shows. +pub(crate) fn property_constraints_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + 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", &platform_value_to_json(declared)?, 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 { + 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) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index a205ca2023c..340856f3800 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -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; @@ -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, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 3770d792f10..be6e7cbcb48 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,4 @@ +use crate::data_contract::DocumentWasm; use crate::data_contract::document_type_distinct_from::{ DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, distinct_from_for_document_type, @@ -10,6 +11,11 @@ use crate::data_contract::document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, immutable_properties_for_document_type, }; +use crate::data_contract::document_type_property_constraints::{ + DocumentPropertyConstraintArrayJs, DocumentPropertyConstraintMapJs, + DocumentPropertyConstraintViolationJs, check_property_constraints, + property_constraints_for_document_type, +}; use crate::data_contract::document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, references_for_document_type, }; @@ -44,6 +50,7 @@ use dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dpp::data_contract::{ DataContract, GroupContractPosition, TokenConfiguration, TokenContractPosition, }; +use dpp::platform_value::string_encoding::Encoding; use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; use dpp::platform_value::string_encoding::{decode, encode}; use dpp::platform_value::{Value, ValueMap}; @@ -814,6 +821,92 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + /// All `propertyConstraints` rules of one document type, in name order, + /// the order consensus checks them in: each rule's name, the rule as the + /// schema declares it, every property it reads and how, and whether it + /// reads `$ownerId` (then a transfer or a purchase is judged against it + /// too). + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name, so "no such + /// type" and "no rules" stay distinguishable. + /// + /// The keyword is only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports + /// none, which is exactly what consensus enforced at that version, while + /// `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypePropertyConstraints")] + pub fn document_type_property_constraints( + &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 rules = property_constraints_for_document_type(document_type)?; + Ok(JsValue::from(rules).into()) + } + + /// Every document type that declares `propertyConstraints`, keyed by + /// document type name. + /// + /// Document types with no rules are omitted, so an empty `Map` means + /// "this contract declares no propertyConstraints at all". + #[wasm_bindgen(getter = "documentPropertyConstraints")] + pub fn document_property_constraints(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let rules = property_constraints_for_document_type(document_type.as_ref())?; + if rules.length() > 0 { + map.set(&JsValue::from_str(name), &rules.into()); + } + } + + Ok(JsValue::from(map).into()) + } + + /// The first `propertyConstraints` rule `document` breaks, in name order, + /// evaluated with the same code consensus runs on a create or replace: its + /// properties, and its owner for `$ownerId`. `undefined` when it meets + /// every rule of its document type. + /// + /// A pre-check, so an app can refuse a document before paying for a + /// transition consensus would refuse with + /// `DocumentPropertyConstraintViolatedError` (code 10422). It judges the + /// rules alone, not the document's JSON schema. Throws when the document + /// belongs to another contract or names a document type this one lacks. + #[wasm_bindgen(js_name = "checkDocumentPropertyConstraints")] + pub fn check_document_property_constraints( + &self, + document: &DocumentWasm, + ) -> WasmDppResult { + let document_contract_id: Identifier = document.data_contract_id.into(); + if document_contract_id != self.0.id() { + return Err(WasmDppError::invalid_argument(format!( + "the document belongs to contract {}, not this one", + document_contract_id.to_string(Encoding::Base58) + ))); + } + let document_type_name = &document.document_type_name; + let document_type = self + .0 + .document_type_optional_for_name(document_type_name) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + Ok(check_property_constraints(document_type, &document.document)?.into()) + } + /// All `encryptedFor` declarations of one document type, in schema /// property order: which byte array properties are encrypted, for whom, /// under which key ids and under which scheme. diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts new file mode 100644 index 00000000000..5a19ca36eab --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -0,0 +1,260 @@ +/** + * Verifies the `propertyConstraints` surface introduced with protocol version + * 14. + * + * A document type names rules its documents' properties must meet: integer + * comparisons and `in`, string and identifier comparisons (with `$ownerId`), + * `present` / `absent`, and `anyOf` / `allOf` / `not` over them. Consensus + * refuses a broken rule with code 10422. What the JS layer offers is + * discovery (which rules a type declares and what they read) and a pre-check + * that evaluates a document with the code consensus runs. + */ +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 = 'CXH2kZCATjvDTnQAPVg28EgPg9WySUvwvnR5ZkmNqY5i'; +const otherId = '9tSsCqKHTZ8ro16MydChSxgHBukFW36eMLJKKRtebJEn'; + +const identifierProperty = (position: number) => ({ + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + position, +}); + +/** + * An `offer` declaring one rule of each family, next to a `plain` type + * declaring none. + */ +const schemas = { + offer: { + type: 'object', + properties: { + price: { type: 'integer', minimum: 0, position: 0 }, + fee: { type: 'integer', minimum: 0, position: 1 }, + discount: { type: 'integer', minimum: 0, position: 2 }, + status: { + type: 'string', enum: ['open', 'closed'], maxLength: 10, position: 3, + }, + closedAt: { type: 'integer', minimum: 0, position: 4 }, + sellerId: identifierProperty(5), + }, + required: ['price', 'fee'], + additionalProperties: false, + propertyConstraints: { + closedNeedsClosedAt: { + anyOf: [{ notEqual: ['status', { const: 'closed' }] }, { present: 'closedAt' }], + }, + discountBelowPrice: { lessThan: ['discount', 'price'] }, + perUnitFee: { greaterThanOrEqual: [{ divide: ['price', 'fee'] }, 1] }, + sellerIsOwner: { + anyOf: [{ absent: 'sellerId' }, { equal: ['sellerId', '$ownerId'] }], + }, + tieredFee: { in: ['fee', [1, 10, 25]] }, + }, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(contractSchemas: Record, platformVersion = 14) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: contractSchemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +function offer( + contract: InstanceType, + properties: Record, + owner = ownerId, +) { + return new wasm.Document({ + properties: { price: 100, fee: 10, ...properties }, + documentTypeName: 'offer', + dataContractId: contract.id, + ownerId: owner, + revision: BigInt(1), + }); +} + +describe('DataContract: propertyConstraints (v14)', () => { + describe('documentTypePropertyConstraints()', () => { + it('should list every rule in name order with what it reads', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypePropertyConstraints('offer')).to.deep.equal([ + { + name: 'closedNeedsClosedAt', + rule: schemas.offer.propertyConstraints.closedNeedsClosedAt, + reads: [ + { path: 'status', kind: 'text' }, + { path: 'closedAt', kind: 'presence' }, + ], + readsOwner: false, + }, + { + name: 'discountBelowPrice', + rule: schemas.offer.propertyConstraints.discountBelowPrice, + reads: [ + { path: 'discount', kind: 'value' }, + { path: 'price', kind: 'value' }, + ], + readsOwner: false, + }, + { + name: 'perUnitFee', + rule: schemas.offer.propertyConstraints.perUnitFee, + reads: [ + { path: 'price', kind: 'value' }, + { path: 'fee', kind: 'value' }, + ], + readsOwner: false, + }, + { + name: 'sellerIsOwner', + rule: schemas.offer.propertyConstraints.sellerIsOwner, + reads: [ + { path: 'sellerId', kind: 'presence' }, + { path: 'sellerId', kind: 'identifier' }, + ], + readsOwner: true, + }, + { + name: 'tieredFee', + rule: schemas.offer.propertyConstraints.tieredFee, + reads: [{ path: 'fee', kind: 'value' }], + readsOwner: false, + }, + ]); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypePropertyConstraints('plain')).to.deep.equal([]); + }); + + it('should throw for an unknown document type', () => { + const contract = buildContract(schemas); + + expect(() => contract.documentTypePropertyConstraints('doesNotExist')).to.throw(/not found/); + }); + + it('should key the types declaring rules in documentPropertyConstraints', () => { + const contract = buildContract(schemas); + const byType = contract.documentPropertyConstraints; + + expect([...byType.keys()]).to.deep.equal(['offer']); + expect(byType.get('offer')).to.have.length(5); + }); + + /** + * Parsers before protocol version 14 ignore the keyword, so a contract + * read at such a version reports no rules: exactly what consensus + * enforced there. + */ + it('should report no rules on a pre-v14 contract', () => { + const contract = buildContract(schemas, 13); + + expect(contract.documentTypePropertyConstraints('offer')).to.deep.equal([]); + expect(contract.checkDocumentPropertyConstraints(offer(contract, { discount: 200 }))) + .to.equal(undefined); + }); + }); + + describe('checkDocumentPropertyConstraints()', () => { + it('should report nothing for a document meeting every rule', () => { + const contract = buildContract(schemas); + + expect(contract.checkDocumentPropertyConstraints(offer(contract, {}))).to.equal(undefined); + expect(contract.checkDocumentPropertyConstraints(offer(contract, { + status: 'closed', closedAt: 1000, sellerId: ownerId, discount: 5, + }))).to.equal(undefined); + }); + + it('should report the first rule broken, in name order, as consensus would', () => { + const contract = buildContract(schemas); + const violationOf = (properties: Record, owner = ownerId) => ( + contract.checkDocumentPropertyConstraints(offer(contract, properties, owner)) + ); + + expect(violationOf({ status: 'closed' })).to.deep.include({ + rule: 'closedNeedsClosedAt', violation: 'NotMet', + }); + expect(violationOf({ discount: 200 })).to.deep.include({ + rule: 'discountBelowPrice', violation: 'NotMet', + }); + // A fee of 0 divides by zero before the tier rule is reached + expect(violationOf({ fee: 0 })).to.deep.include({ + rule: 'perUnitFee', violation: 'DivisionByZero', + }); + expect(violationOf({ fee: 5 })).to.deep.include({ + rule: 'tieredFee', violation: 'NotMet', + }); + expect(violationOf({ fee: 5 }).message).to.be.a('string'); + }); + + it('should read the document owner for $ownerId', () => { + const contract = buildContract(schemas); + + expect(contract.checkDocumentPropertyConstraints(offer(contract, { sellerId: otherId }))) + .to.deep.include({ rule: 'sellerIsOwner', violation: 'NotMet' }); + expect(contract.checkDocumentPropertyConstraints( + offer(contract, { sellerId: otherId }, otherId), + )).to.equal(undefined); + }); + + it('should report nothing for a document type declaring no rules', () => { + const contract = buildContract(schemas); + const document = new wasm.Document({ + properties: { message: 'hi' }, + documentTypeName: 'plain', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + + expect(contract.checkDocumentPropertyConstraints(document)).to.equal(undefined); + }); + + it('should throw for a document of another contract or an unknown type', () => { + const contract = buildContract(schemas); + const foreign = new wasm.Document({ + properties: { price: 100, fee: 10 }, + documentTypeName: 'offer', + dataContractId: otherId, + ownerId, + revision: BigInt(1), + }); + const unknownType = new wasm.Document({ + properties: {}, + documentTypeName: 'doesNotExist', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + + expect(() => contract.checkDocumentPropertyConstraints(foreign)).to.throw(/another contract|not this one/); + expect(() => contract.checkDocumentPropertyConstraints(unknownType)).to.throw(/not found/); + }); + }); +}); From 802b4dfe5a2652957458df4624724d860b4aca24 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 27 Sep 2026 21:58:29 +0700 Subject: [PATCH 2/2] fix(sdk): report propertyConstraints integer literals past 2^53 as bigint Discovery converted each declared rule through serde_json and the JSON-compatible serializer, which throws on an integer past Number.MAX_SAFE_INTEGER, so one such literal (a comparison, an ifAbsent default or an `in` value) made documentTypePropertyConstraints and documentPropertyConstraints throw for the whole type or contract. Rules now convert directly: an integer is a number while it is exact in JavaScript and a bigint past that. The TypeScript operand types admit bigint. Co-Authored-By: Claude Opus 5.5 --- .../document_type_property_constraints.rs | 80 +++++++++++++++++-- .../unit/DocumentPropertyConstraints.spec.ts | 36 +++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index 490d9d3f0a5..b5d5c827a37 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -15,14 +15,13 @@ //! so an app can find a broken rule before paying for a refused transition. use crate::error::{WasmDppError, WasmDppResult}; -use crate::serialization::conversions::platform_value_to_json; 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, Object, Reflect}; +use js_sys::{Array, BigInt, Object, Reflect}; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -31,7 +30,8 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" /** * An integer expression of a `propertyConstraints` rule. * - * - a number: an integer value; + * - 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; @@ -42,8 +42,9 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" */ export type PropertyConstraintExpression = | number + | bigint | string - | { ifAbsent: [path: string, value: number] } + | { ifAbsent: [path: string, value: number | bigint] } | { add: PropertyConstraintExpression[] } | { multiply: PropertyConstraintExpression[] } | { subtract: [PropertyConstraintExpression, PropertyConstraintExpression] } @@ -86,7 +87,7 @@ export type PropertyConstraintCondition = | { lessThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } - | { in: [PropertyConstraintExpression, number[]] | [string | { ifAbsent: [path: string, value: string] }, string[]] } + | { in: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } | { present: string } | { absent: string } | { anyOf: PropertyConstraintCondition[] } @@ -183,12 +184,75 @@ fn violation_name(violation: PropertyConstraintViolation) -> &'static str { } } +/// `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 { + 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 -/// and what `toJSON()` shows. +/// 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 { @@ -211,7 +275,7 @@ pub(crate) fn property_constraints_for_document_type( schema" )) })?; - set_field(&object, "rule", &platform_value_to_json(declared)?, name)?; + set_field(&object, "rule", &rule_to_js(declared, name)?, name)?; let reads = Array::new(); for (path, read) in constraint.property_reads() { diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 5a19ca36eab..b18dae30a63 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -167,6 +167,42 @@ describe('DataContract: propertyConstraints (v14)', () => { expect(byType.get('offer')).to.have.length(5); }); + it('should report integer literals past Number.MAX_SAFE_INTEGER exactly, as bigint', () => { + const big = 9007199254740993n; // 2 ** 53 + 1, which a number rounds + const rules = { + balanceBelowCap: { lessThan: [{ ifAbsent: ['balance', -big] }, big] }, + knownTier: { in: ['tier', [1, big]] }, + }; + const contract = buildContract({ + ledger: { + type: 'object', + properties: { + balance: { type: 'integer', position: 0 }, + tier: { type: 'integer', position: 1 }, + }, + additionalProperties: false, + propertyConstraints: rules, + }, + }); + const expected = [ + { + name: 'balanceBelowCap', + rule: rules.balanceBelowCap, + reads: [{ path: 'balance', kind: 'value' }], + readsOwner: false, + }, + { + name: 'knownTier', + rule: rules.knownTier, + reads: [{ path: 'tier', kind: 'value' }], + readsOwner: false, + }, + ]; + + expect(contract.documentTypePropertyConstraints('ledger')).to.deep.equal(expected); + expect(contract.documentPropertyConstraints.get('ledger')).to.deep.equal(expected); + }); + /** * Parsers before protocol version 14 ignore the keyword, so a contract * read at such a version reports no rules: exactly what consensus