From 96568b6a95cab3d17faf72101dac4db237238afe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 27 Sep 2026 19:20:39 +0700 Subject: [PATCH 1/2] feat(sdk): propertyConstraints rules and pre-check in the Swift SDK and iOS example app rs-sdk-ffi gains dash_sdk_data_contract_get_property_constraints (a document type's rules as a JSON array, in name order, with what each reads and whether it reads $ownerId) and dash_sdk_data_contract_check_property_constraints (the first rule a document to create breaks, or JSON null). Both take the contract as its platform serialization, read at the SDK's protocol version, and match wasm-dpp2's JSON shapes. The check builds the document the way dash_sdk_document_create does (its parsing and building now shared as two helpers) and judges it with DPP's validate_property_constraints. The Swift SDK decodes both into DocumentPropertyConstraint and PropertyConstraintViolation behind thin SDK wrappers, with PersistentDocumentType helpers reading the stored contract bytes. The example app lists the rules on the document type screen and refuses to broadcast a document that breaks one, naming the rule and the reason. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk-ffi/src/data_contract/mod.rs | 8 + .../src/data_contract/property_constraints.rs | 850 ++++++++++++++++++ packages/rs-sdk-ffi/src/document/create.rs | 47 +- packages/rs-sdk-ffi/src/document/helpers.rs | 62 +- packages/rs-sdk-ffi/src/document/mod.rs | 1 + .../Utils/DocumentPropertyConstraints.swift | 345 +++++++ .../Models/PersistentDocumentType.swift | 54 ++ .../Views/DocumentTypeDetailsView.swift | 119 +++ .../SwiftExampleApp/Views/DocumentsView.swift | 40 +- .../DocumentPropertyConstraintsTests.swift | 381 ++++++++ 10 files changed, 1871 insertions(+), 36 deletions(-) create mode 100644 packages/rs-sdk-ffi/src/data_contract/property_constraints.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift diff --git a/packages/rs-sdk-ffi/src/data_contract/mod.rs b/packages/rs-sdk-ffi/src/data_contract/mod.rs index 24f6ea93fa1..2cd73e05d98 100644 --- a/packages/rs-sdk-ffi/src/data_contract/mod.rs +++ b/packages/rs-sdk-ffi/src/data_contract/mod.rs @@ -17,7 +17,10 @@ //! - `dash_sdk_data_contract_destroy` — destructor for handles //! produced by the query layer. //! - The query function re-exports from `queries::*`. +//! - The `propertyConstraints` rules of a document type, and the +//! pre-check of a document against them (`property_constraints`). +mod property_constraints; mod put; mod queries; mod util; @@ -55,6 +58,11 @@ pub unsafe extern "C" fn dash_sdk_data_contract_destroy(handle: *mut DataContrac } } +pub use property_constraints::{ + dash_sdk_data_contract_check_property_constraints, + dash_sdk_data_contract_get_property_constraints, +}; + // Re-export query functions pub use queries::{ dash_sdk_data_contract_fetch, dash_sdk_data_contract_fetch_history, diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs new file mode 100644 index 00000000000..c04403a96bb --- /dev/null +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -0,0 +1,850 @@ +//! `propertyConstraints` rules (protocol version 14): the named conditions a +//! document type holds every created or replaced document's properties to. +//! Consensus refuses a document breaking one with +//! `DocumentPropertyConstraintViolatedError` (basic code 10422), and a refused +//! state transition is still paid for. +//! +//! Two functions expose what DPP already knows about them: +//! +//! - [`dash_sdk_data_contract_get_property_constraints`]: the rules a document +//! type declares, in name order (the order consensus checks them in), each +//! with what it reads, as a JSON array; +//! - [`dash_sdk_data_contract_check_property_constraints`]: the first rule a +//! document to create breaks, judged by DPP's own +//! `DocumentType::validate_property_constraints` (the check consensus runs, +//! evaluating each rule with `PropertyConstraint::violation`), as JSON, or +//! JSON `null` when it meets them all. +//! +//! The JSON shapes are those of wasm-dpp2's `documentTypePropertyConstraints` +//! and `checkDocumentPropertyConstraints`, key for key, so every SDK reports a +//! rule and a violation alike. +//! +//! Both take the contract as its platform serialization: the bytes a client +//! keeps beside a fetched contract, which it also hands +//! `dash_sdk_add_known_contracts`. They read it at the SDK's protocol version, +//! the version the SDK builds and sends documents at, so a client refreshing +//! that version sees the rules consensus applies: before protocol version 14 a +//! document type carries none. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use dash_sdk::dpp::consensus::basic::document::PropertyConstraintViolation; +use dash_sdk::dpp::consensus::basic::BasicError; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::property_constraints::PropertyRead; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::{DataContract, Identifier}; +use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted; +use dash_sdk::dpp::version::PlatformVersion; +use serde_json::json; + +use crate::document::{build_document_from_properties, parse_document_properties_json}; +use crate::sdk::SDKWrapper; +use crate::types::SDKHandle; +use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; + +/// The doctype keyword declaring the rules, whose entries are the rules as +/// the contract wrote them. +const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; + +/// Get the `propertyConstraints` rules of a document type as a JSON array +/// +/// Each element is +/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool }`: +/// the rule's name (its key in `propertyConstraints`), the rule exactly as the +/// document type's schema declares it, every property it reads in declared +/// order (`kind` is `"value"` for an integer operand, `"presence"` for +/// `present` / `absent`, `"text"` for a string comparison and `"identifier"` +/// for an identifier comparison; `$ownerId` is no property and is not listed), +/// and whether it reads `$ownerId`, which makes a transfer or a purchase answer +/// to it too. Rules are listed in name order, the order consensus checks them +/// in. A document type declaring none gives `[]`, and so does every document +/// type when the SDK's protocol version is below 14. +/// +/// The contract is read from its platform serialization at the SDK's protocol +/// version, as `dash_sdk_add_known_contracts` reads it, without re-validating +/// it. +/// +/// Errors: `InvalidParameter` for a null or empty argument or a document type +/// name that is not UTF-8, `SerializationError` for bytes that are not a +/// contract, `NotFound` for a document type the contract does not declare. +/// +/// # Safety +/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. +/// - `serialized_contract` must point to `serialized_contract_len` readable bytes. +/// - `document_type` must point to a NUL-terminated C string valid for the duration of the call. +/// - On success the result's `data` is a heap-allocated C string the caller frees with +/// `dash_sdk_string_free`; on error the caller frees `error` with `dash_sdk_error_free`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_data_contract_get_property_constraints( + sdk_handle: *const SDKHandle, + serialized_contract: *const u8, + serialized_contract_len: usize, + document_type: *const c_char, +) -> DashSDKResult { + if sdk_handle.is_null() || serialized_contract.is_null() || document_type.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle, serialized contract or document type is null".to_string(), + )); + } + + // SAFETY: the caller guarantees `sdk_handle` points to a live SDKWrapper + let wrapper = &*(sdk_handle as *const SDKWrapper); + // SAFETY: non-null, and the caller guarantees NUL termination + let document_type_name = match CStr::from_ptr(document_type).to_str() { + Ok(name) => name, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + + let rules = deserialize_contract( + serialized_contract, + serialized_contract_len, + wrapper.sdk.version(), + ) + .and_then(|contract| { + let document_type = document_type_named(&contract, document_type_name)?; + property_constraints_json(document_type) + }); + json_result(rules) +} + +/// Check a document to create against its document type's `propertyConstraints` +/// +/// `properties_json` is the document's properties as `dash_sdk_document_create` +/// takes them (a JSON object keyed by property name, byte arrays as hex or +/// base64 and identifiers as base58 or hex strings), and `owner_id` the 32 +/// bytes of the identity that will own it, which `$ownerId` reads. The +/// properties are turned into the document `dash_sdk_document_create` builds, +/// with the same parsing, sanitizing and `create_document_from_data`, so every +/// value is typed as it would be sent; the document is then judged by DPP's +/// `validate_property_constraints`, the check consensus runs on a create: +/// every rule, in name order, evaluated by `PropertyConstraint::violation`. +/// Nothing but the rules is checked: not the JSON schema, not the state. +/// +/// The result is the first rule broken, as +/// `{ "rule": string, "violation": string, "message": string }`, with +/// `violation` one of `"NotMet"`, `"Overflow"`, `"DivisionByZero"`, +/// `"NegativeExponent"` and `"NotAnInteger"` and `message` the reason +/// consensus gives, or JSON `null` when the document meets every rule (always +/// so below protocol version 14). +/// +/// The contract is read from its platform serialization at the SDK's protocol +/// version, as `dash_sdk_add_known_contracts` reads it, without re-validating +/// it. +/// +/// Errors: `InvalidParameter` for a null or empty argument, text that is not +/// UTF-8, properties that are not a JSON object, or properties no document can +/// be built from; `SerializationError` for bytes that are not a contract; +/// `NotFound` for a document type the contract does not declare. +/// +/// # Safety +/// - `sdk_handle` must be a valid, non-null pointer to an initialized `SDKHandle`. +/// - `serialized_contract` must point to `serialized_contract_len` readable bytes. +/// - `document_type` and `properties_json` must point to NUL-terminated C strings valid for the +/// duration of the call. +/// - `owner_id` must point to 32 readable bytes. +/// - On success the result's `data` is a heap-allocated C string the caller frees with +/// `dash_sdk_string_free`; on error the caller frees `error` with `dash_sdk_error_free`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_data_contract_check_property_constraints( + sdk_handle: *const SDKHandle, + serialized_contract: *const u8, + serialized_contract_len: usize, + document_type: *const c_char, + properties_json: *const c_char, + owner_id: *const u8, +) -> DashSDKResult { + if sdk_handle.is_null() + || serialized_contract.is_null() + || document_type.is_null() + || properties_json.is_null() + || owner_id.is_null() + { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle, serialized contract, document type, properties JSON or owner ID is null" + .to_string(), + )); + } + + // SAFETY: the caller guarantees `sdk_handle` points to a live SDKWrapper + let wrapper = &*(sdk_handle as *const SDKWrapper); + // SAFETY: non-null, and the caller guarantees NUL termination + let document_type_name = match CStr::from_ptr(document_type).to_str() { + Ok(name) => name, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + // SAFETY: non-null, and the caller guarantees NUL termination + let properties_str = match CStr::from_ptr(properties_json).to_str() { + Ok(properties) => properties, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + // SAFETY: non-null, and the caller guarantees 32 readable bytes + let owner_bytes = &*(owner_id as *const [u8; 32]); + let owner_id = Identifier::new(*owner_bytes); + + // Read once, so the contract and the document are read at one version + let platform_version = wrapper.sdk.version(); + let violation = parse_document_properties_json(properties_str).and_then(|properties| { + let contract = deserialize_contract( + serialized_contract, + serialized_contract_len, + platform_version, + )?; + let document_type = document_type_named(&contract, document_type_name)?; + // The id is derived from the entropy, and no rule can read it + let document = build_document_from_properties( + document_type, + properties, + owner_id, + [0u8; 32], + platform_version, + ) + .map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Failed to build the document from its properties: {}", e), + ) + })?; + property_constraint_violation_json(document_type, &document, platform_version) + }); + json_result(violation) +} + +/// Read the contract a caller holds as its platform serialization, at +/// `platform_version` (the SDK's) and without re-validating it: the way +/// `dash_sdk_add_known_contracts` and the token transitions read one. +/// +/// # Safety +/// - `serialized_contract` must be non-null and point to `serialized_contract_len` readable bytes. +unsafe fn deserialize_contract( + serialized_contract: *const u8, + serialized_contract_len: usize, + platform_version: &PlatformVersion, +) -> Result { + if serialized_contract_len == 0 { + return Err(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "Serialized contract is empty".to_string(), + )); + } + // SAFETY: the caller guarantees `serialized_contract_len` readable bytes + let bytes = std::slice::from_raw_parts(serialized_contract, serialized_contract_len); + DataContract::versioned_deserialize_untrusted(bytes, false, platform_version).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!("Failed to deserialize contract: {}", e), + ) + }) +} + +/// The document type `contract` declares under `name`. +fn document_type_named<'a>( + contract: &'a DataContract, + name: &str, +) -> Result, DashSDKError> { + contract + .document_type_optional_for_name(name) + .ok_or_else(|| { + DashSDKError::new( + DashSDKErrorCode::NotFound, + format!("Document type '{}' not found in the data contract", name), + ) + }) +} + +/// Every rule of `document_type`'s `propertyConstraints`, in name order, as +/// the JSON array `dash_sdk_data_contract_get_property_constraints` returns. +/// +/// The parsed rules give the name, the reads and whether the owner is read; +/// the rule itself is the schema's declaration, what the contract wrote. +fn property_constraints_json( + document_type: DocumentTypeRef<'_>, +) -> Result { + let declarations = document_type + .schema() + .get_optional_value(PROPERTY_CONSTRAINTS_KEYWORD) + .ok() + .flatten(); + + let constraints = document_type.property_constraints(); + let mut rules = Vec::with_capacity(constraints.len()); + for (name, constraint) in constraints { + let declared = declarations + .and_then(|declarations| declarations.get_optional_value(name).ok().flatten()) + .ok_or_else(|| { + DashSDKError::new( + DashSDKErrorCode::InternalError, + format!( + "The propertyConstraints rule '{}' is missing from the document type's schema", + name + ), + ) + })?; + let rule = serde_json::to_value(declared).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::SerializationError, + format!( + "Failed to serialize the propertyConstraints rule '{}': {}", + name, e + ), + ) + })?; + let reads: Vec = constraint + .property_reads() + .into_iter() + .map(|(path, read)| json!({ "path": path, "kind": read_kind_name(read) })) + .collect(); + rules.push(json!({ + "name": name, + "rule": rule, + "reads": reads, + "readsOwner": constraint.reads_owner(), + })); + } + Ok(serde_json::Value::Array(rules)) +} + +/// The first rule of `document_type`'s `propertyConstraints` that `document` +/// breaks, judged as consensus judges a create (its properties, and its owner +/// for `$ownerId`), as the JSON `dash_sdk_data_contract_check_property_constraints` +/// returns: JSON `null` when it meets them all. +fn property_constraint_violation_json( + document_type: DocumentTypeRef<'_>, + document: &Document, + platform_version: &PlatformVersion, +) -> Result { + let data = Value::from(document.properties().clone()); + let result = document_type + .validate_property_constraints(&data, Some(document.owner_id()), platform_version) + .map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::ProtocolError, + format!("Failed to check the propertyConstraints rules: {}", e), + ) + })?; + match result.first_error() { + None => Ok(serde_json::Value::Null), + Some(ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError( + error, + ))) => Ok(json!({ + "rule": error.constraint(), + "violation": violation_name(error.violation()), + "message": error.violation().to_string(), + })), + Some(other) => Err(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!( + "Unexpected error checking the propertyConstraints rules: {}", + other + ), + )), + } +} + +/// The name a read kind goes by in the rule JSON. +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 the violation JSON, 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", + } +} + +/// `value` as a C string result the caller frees with `dash_sdk_string_free`. +fn json_result(value: Result) -> DashSDKResult { + let value = match value { + Ok(value) => value, + Err(error) => return DashSDKResult::error(error), + }; + // JSON escapes every control character, so the text holds no NUL byte + match CString::new(value.to_string()) { + Ok(text) => DashSDKResult::success_string(text.into_raw()), + Err(e) => DashSDKResult::error(FFIError::from(e).into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash_sdk_error_free; + use crate::test_utils::test_utils::destroy_mock_sdk_handle; + use crate::types::dash_sdk_string_free; + use dash_sdk::dpp::data_contract::DataContractFactory; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use dash_sdk::dpp::serialization::PlatformSerializableWithPlatformVersion; + use dash_sdk::SdkBuilder; + + const OWNER: [u8; 32] = [1; 32]; + const OTHER: [u8; 32] = [2; 32]; + + /// The rules of the `offer` type, as declared: one of each family (a string + /// constant with `present`, an integer comparison, a division, `$ownerId` + /// with `absent`, and `in`), in a declaration order that is not name order. + fn offer_rules() -> serde_json::Value { + json!({ + "tieredFee": { "in": ["fee", [1, 10, 25]] }, + "discountBelowPrice": { "lessThan": ["discount", "price"] }, + "sellerIsOwner": { + "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] + }, + "closedNeedsClosedAt": { + "anyOf": [ + { "notEqual": ["status", { "const": "closed" }] }, + { "present": "closedAt" } + ] + }, + "perUnitFee": { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 1] } + }) + } + + /// An `offer` type declaring `offer_rules()`, beside a `plain` type + /// declaring none, created at `platform_version`. + fn contract(platform_version: &PlatformVersion) -> DataContract { + let rules = Value::from(offer_rules()); + let documents = platform_value!({ + "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": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 5 + } + }, + "required": ["price", "fee"], + "additionalProperties": false, + "propertyConstraints": rules + }, + "plain": { + "type": "object", + "properties": { + "message": { "type": "string", "maxLength": 64, "position": 0 } + }, + "additionalProperties": false + } + }); + + let factory = DataContractFactory::new(platform_version.protocol_version) + .expect("factory for the protocol version"); + factory + .create_with_value_config(Identifier::new(OWNER), 1, documents, None, None) + .expect("offer contract") + .data_contract() + .clone() + } + + fn serialized_contract(platform_version: &PlatformVersion) -> Vec { + contract(platform_version) + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialized contract") + } + + /// A mock SDK handle pinned to `platform_version`. + fn sdk_handle(platform_version: &'static PlatformVersion) -> *mut SDKHandle { + let mut wrapper = SDKWrapper::new_mock(); + wrapper.sdk = SdkBuilder::new_mock() + .with_version(platform_version) + .build() + .expect("mock SDK"); + Box::into_raw(Box::new(wrapper)) as *mut SDKHandle + } + + /// The result's string, or its error code and message; frees both. + fn take(result: DashSDKResult) -> Result { + unsafe { + if !result.error.is_null() { + let error = &*result.error; + let outcome = ( + error.code, + CStr::from_ptr(error.message).to_string_lossy().into_owned(), + ); + dash_sdk_error_free(result.error); + return Err(outcome); + } + let text = result.data as *mut c_char; + let value = + serde_json::from_str(&CStr::from_ptr(text).to_string_lossy()).expect("JSON result"); + dash_sdk_string_free(text); + Ok(value) + } + } + + fn rules_of( + sdk: *mut SDKHandle, + contract: &[u8], + document_type: &str, + ) -> Result { + let document_type = CString::new(document_type).expect("no NUL in the type name"); + take(unsafe { + dash_sdk_data_contract_get_property_constraints( + sdk, + contract.as_ptr(), + contract.len(), + document_type.as_ptr(), + ) + }) + } + + fn check( + sdk: *mut SDKHandle, + contract: &[u8], + document_type: &str, + properties: serde_json::Value, + owner: [u8; 32], + ) -> Result { + check_json(sdk, contract, document_type, &properties.to_string(), owner) + } + + fn check_json( + sdk: *mut SDKHandle, + contract: &[u8], + document_type: &str, + properties_json: &str, + owner: [u8; 32], + ) -> Result { + let document_type = CString::new(document_type).expect("no NUL in the type name"); + let properties_json = CString::new(properties_json).expect("no NUL in the JSON"); + take(unsafe { + dash_sdk_data_contract_check_property_constraints( + sdk, + contract.as_ptr(), + contract.len(), + document_type.as_ptr(), + properties_json.as_ptr(), + owner.as_ptr(), + ) + }) + } + + fn base58(bytes: [u8; 32]) -> String { + Identifier::new(bytes).to_string(Encoding::Base58) + } + + #[test] + fn should_list_every_rule_in_name_order_with_what_it_reads() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let declared = offer_rules(); + + let rules = rules_of(sdk, &contract, "offer"); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + rules.expect("rules of offer"), + json!([ + { + "name": "closedNeedsClosedAt", + "rule": declared["closedNeedsClosedAt"], + "reads": [ + { "path": "status", "kind": "text" }, + { "path": "closedAt", "kind": "presence" } + ], + "readsOwner": false + }, + { + "name": "discountBelowPrice", + "rule": declared["discountBelowPrice"], + "reads": [ + { "path": "discount", "kind": "value" }, + { "path": "price", "kind": "value" } + ], + "readsOwner": false + }, + { + "name": "perUnitFee", + "rule": declared["perUnitFee"], + "reads": [ + { "path": "price", "kind": "value" }, + { "path": "fee", "kind": "value" } + ], + "readsOwner": false + }, + { + "name": "sellerIsOwner", + "rule": declared["sellerIsOwner"], + "reads": [ + { "path": "sellerId", "kind": "presence" }, + { "path": "sellerId", "kind": "identifier" } + ], + "readsOwner": true + }, + { + "name": "tieredFee", + "rule": declared["tieredFee"], + "reads": [{ "path": "fee", "kind": "value" }], + "readsOwner": false + } + ]) + ); + } + + #[test] + fn should_list_no_rules_for_a_document_type_declaring_none() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let rules = rules_of(sdk, &contract, "plain"); + destroy_mock_sdk_handle(sdk); + + assert_eq!(rules.expect("rules of plain"), json!([])); + } + + #[test] + fn should_refuse_a_document_type_the_contract_does_not_declare() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let rules = rules_of(sdk, &contract, "letter"); + let violation = check(sdk, &contract, "letter", json!({}), OWNER); + destroy_mock_sdk_handle(sdk); + + for outcome in [rules, violation] { + let (code, message) = outcome.expect_err("an unknown type is refused"); + assert_eq!(code, DashSDKErrorCode::NotFound); + assert!(message.contains("'letter' not found"), "{message}"); + } + } + + #[test] + fn should_report_nothing_for_a_document_meeting_every_rule() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let bare = check( + sdk, + &contract, + "offer", + json!({ "price": 100, "fee": 10 }), + OWNER, + ); + // The seller arrives as base58 text and is typed an identifier, as the + // create path types it, so it equals the owner + let full = check( + sdk, + &contract, + "offer", + json!({ + "price": 100, + "fee": 10, + "discount": 5, + "status": "closed", + "closedAt": 1000, + "sellerId": base58(OWNER) + }), + OWNER, + ); + let plain = check(sdk, &contract, "plain", json!({ "message": "hi" }), OWNER); + destroy_mock_sdk_handle(sdk); + + assert_eq!(bare.expect("checked"), serde_json::Value::Null); + assert_eq!(full.expect("checked"), serde_json::Value::Null); + assert_eq!(plain.expect("checked"), serde_json::Value::Null); + } + + #[test] + fn should_report_the_first_rule_broken_in_name_order() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let violation_of = |properties: serde_json::Value| { + let mut document = json!({ "price": 100, "fee": 10 }); + for (key, value) in properties.as_object().expect("an object") { + document[key] = value.clone(); + } + check(sdk, &contract, "offer", document, OWNER).expect("checked") + }; + + // A string constant: a closed offer must say when it closed + let closed = violation_of(json!({ "status": "closed" })); + // An integer comparison + let discounted = violation_of(json!({ "discount": 200 })); + // A fee of 0 divides by zero before the `in` rule is reached + let free = violation_of(json!({ "fee": 0 })); + // `in` + let untiered = violation_of(json!({ "fee": 5 })); + // Two broken rules: the first in name order is reported + let both = violation_of(json!({ "discount": 200, "fee": 5 })); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + closed, + json!({ + "rule": "closedNeedsClosedAt", + "violation": "NotMet", + "message": PropertyConstraintViolation::NotMet.to_string() + }) + ); + assert_eq!(discounted["rule"], "discountBelowPrice"); + assert_eq!(discounted["violation"], "NotMet"); + assert_eq!( + free, + json!({ + "rule": "perUnitFee", + "violation": "DivisionByZero", + "message": PropertyConstraintViolation::DivisionByZero.to_string() + }) + ); + assert_eq!(untiered["rule"], "tieredFee"); + assert_eq!(untiered["violation"], "NotMet"); + assert_eq!(both["rule"], "discountBelowPrice"); + } + + #[test] + fn should_read_the_owner_for_owner_id() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let offer = json!({ "price": 100, "fee": 10, "sellerId": base58(OTHER) }); + + let owned_by_someone_else = check(sdk, &contract, "offer", offer.clone(), OWNER); + let owned_by_the_seller = check(sdk, &contract, "offer", offer, OTHER); + destroy_mock_sdk_handle(sdk); + + let violation = owned_by_someone_else.expect("checked"); + assert_eq!(violation["rule"], "sellerIsOwner"); + assert_eq!(violation["violation"], "NotMet"); + assert_eq!( + owned_by_the_seller.expect("checked"), + serde_json::Value::Null + ); + } + + /// Parsers before protocol version 14 ignore the keyword, so an SDK at such + /// a version reports no rule and no violation: exactly what consensus + /// enforced there. + #[test] + fn should_report_no_rules_below_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let rules = rules_of(sdk, &contract, "offer"); + let violation = check( + sdk, + &contract, + "offer", + json!({ "price": 100, "fee": 0, "discount": 200 }), + OWNER, + ); + destroy_mock_sdk_handle(sdk); + + assert_eq!(rules.expect("rules of offer"), json!([])); + assert_eq!(violation.expect("checked"), serde_json::Value::Null); + } + + #[test] + fn should_refuse_what_is_not_a_contract_or_a_properties_object() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + + let not_a_contract = rules_of(sdk, &[0xff, 0x00, 0x13], "offer"); + let not_json = check_json(sdk, &contract, "offer", "{price:", OWNER); + let not_an_object = check_json(sdk, &contract, "offer", "[1, 2]", OWNER); + destroy_mock_sdk_handle(sdk); + + assert_eq!( + not_a_contract.expect_err("refused").0, + DashSDKErrorCode::SerializationError + ); + let (code, message) = not_json.expect_err("refused"); + assert_eq!(code, DashSDKErrorCode::InvalidParameter); + assert!(message.contains("Invalid properties JSON"), "{message}"); + let (code, message) = not_an_object.expect_err("refused"); + assert_eq!(code, DashSDKErrorCode::InvalidParameter); + assert!( + message.contains("Failed to convert properties"), + "{message}" + ); + } + + #[test] + fn should_refuse_null_or_empty_arguments() { + let platform_version = PlatformVersion::latest(); + let sdk = sdk_handle(platform_version); + let contract = serialized_contract(platform_version); + let offer = CString::new("offer").expect("no NUL"); + let properties = CString::new("{}").expect("no NUL"); + + let outcomes = unsafe { + [ + take(dash_sdk_data_contract_get_property_constraints( + std::ptr::null(), + contract.as_ptr(), + contract.len(), + offer.as_ptr(), + )), + take(dash_sdk_data_contract_get_property_constraints( + sdk, + contract.as_ptr(), + 0, + offer.as_ptr(), + )), + take(dash_sdk_data_contract_check_property_constraints( + sdk, + contract.as_ptr(), + contract.len(), + offer.as_ptr(), + properties.as_ptr(), + std::ptr::null(), + )), + take(dash_sdk_data_contract_check_property_constraints( + sdk, + std::ptr::null(), + contract.len(), + offer.as_ptr(), + properties.as_ptr(), + OWNER.as_ptr(), + )), + ] + }; + destroy_mock_sdk_handle(sdk); + + for outcome in outcomes { + assert_eq!( + outcome.expect_err("refused").0, + DashSDKErrorCode::InvalidParameter + ); + } + } +} diff --git a/packages/rs-sdk-ffi/src/document/create.rs b/packages/rs-sdk-ffi/src/document/create.rs index 6aa744398d6..5d23d28815c 100644 --- a/packages/rs-sdk-ffi/src/document/create.rs +++ b/packages/rs-sdk-ffi/src/document/create.rs @@ -1,5 +1,6 @@ //! Document creation operations +use crate::document::helpers::{build_document_from_properties, parse_document_properties_json}; use crate::sdk::SDKWrapper; use crate::types::{DashSDKResultDataType, DocumentHandle, SDKHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; @@ -110,25 +111,9 @@ pub unsafe extern "C" fn dash_sdk_document_create( }; // Parse properties JSON - let properties_value: serde_json::Value = match serde_json::from_str(properties_str) { - Ok(v) => v, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Invalid properties JSON: {}", e), - )) - } - }; - - // Convert JSON to platform Value - handle hex strings for byte arrays - let mut properties = match serde_json::from_value::>(properties_value) { - Ok(map) => map, - Err(e) => { - return DashSDKResult::error(DashSDKError::new( - DashSDKErrorCode::InvalidParameter, - format!("Failed to convert properties: {}", e), - )) - } + let properties = match parse_document_properties_json(properties_str) { + Ok(properties) => properties, + Err(error) => return DashSDKResult::error(error), }; let result: Result<(Document, [u8; 32]), FFIError> = wrapper.runtime.block_on(async { @@ -173,21 +158,15 @@ pub unsafe extern "C" fn dash_sdk_document_create( .map_err(|e| FFIError::InternalError(format!("Failed to get document type: {}", e)))?; // Sanitize document properties (convert hex/base64 to bytes, base58 to identifiers, etc.) - use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; - document_type_ref.sanitize_document_properties(&mut properties); - eprintln!("📝 [DOCUMENT CREATE] Sanitized document properties"); - - // Create document with entropy - this will generate the document ID internally - let document = document_type_ref - .create_document_from_data( - properties.into(), - owner_id, - 0, // block_height - will be set by platform - 0, // core_block_height - will be set by platform - entropy, - platform_version, - ) - .map_err(|e| FFIError::InternalError(format!("Failed to create document: {}", e)))?; + // and create the document with entropy - this will generate the document ID internally + let document = build_document_from_properties( + document_type_ref.as_ref(), + properties, + owner_id, + entropy, + platform_version, + ) + .map_err(|e| FFIError::InternalError(format!("Failed to create document: {}", e)))?; Ok((document, entropy)) }); diff --git a/packages/rs-sdk-ffi/src/document/helpers.rs b/packages/rs-sdk-ffi/src/document/helpers.rs index afdc0239f1b..491da439c49 100644 --- a/packages/rs-sdk-ffi/src/document/helpers.rs +++ b/packages/rs-sdk-ffi/src/document/helpers.rs @@ -1,16 +1,76 @@ //! Helper functions for document operations +use std::collections::BTreeMap; + +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::document::Document; +use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; use dash_sdk::dpp::tokens::token_payment_info::v0::TokenPaymentInfoV0; use dash_sdk::dpp::tokens::token_payment_info::TokenPaymentInfo; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::ProtocolError; use crate::types::{ DashSDKGasFeesPaidBy, DashSDKStateTransitionCreationOptions, DashSDKTokenPaymentInfo, }; -use crate::FFIError; +use crate::{DashSDKError, DashSDKErrorCode, FFIError}; + +/// Parse the properties JSON of a document to create, as `dash_sdk_document_create` +/// takes it: a JSON object keyed by property name, each value read into a platform +/// `Value` as JSON writes it (integers, booleans, strings, arrays and objects). Byte +/// arrays and identifiers are still the strings the caller wrote here; +/// [`build_document_from_properties`] decodes them against the document type. +/// +/// Errors carry `InvalidParameter`: the text is not JSON, or not an object. +pub(crate) fn parse_document_properties_json( + properties_json: &str, +) -> Result, DashSDKError> { + let properties_value: serde_json::Value = + serde_json::from_str(properties_json).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Invalid properties JSON: {}", e), + ) + })?; + + // Convert JSON to platform Value - handle hex strings for byte arrays + serde_json::from_value::>(properties_value).map_err(|e| { + DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Failed to convert properties: {}", e), + ) + }) +} + +/// Build the document `dash_sdk_document_create` creates from `properties`: they are +/// sanitized against `document_type` (hex or base64 strings become byte arrays, base58 +/// or hex strings identifiers, integers narrow to their declared width, typed array +/// elements included), then `DocumentType::create_document_from_data` builds the +/// revision-1 document owned by `owner_id`, its id derived from `entropy`, typing the +/// value at each of the document type's identifier paths as an identifier. Block +/// heights are left at 0 for Platform to set. +pub(crate) fn build_document_from_properties( + document_type: DocumentTypeRef<'_>, + mut properties: BTreeMap, + owner_id: Identifier, + entropy: [u8; 32], + platform_version: &PlatformVersion, +) -> Result { + document_type.sanitize_document_properties(&mut properties); + document_type.create_document_from_data( + properties.into(), + owner_id, + 0, // block_height - will be set by platform + 0, // core_block_height - will be set by platform + entropy, + platform_version, + ) +} /// Convert FFI GasFeesPaidBy to Rust enum /// diff --git a/packages/rs-sdk-ffi/src/document/mod.rs b/packages/rs-sdk-ffi/src/document/mod.rs index 1847dbfd98c..95c168486e1 100644 --- a/packages/rs-sdk-ffi/src/document/mod.rs +++ b/packages/rs-sdk-ffi/src/document/mod.rs @@ -35,6 +35,7 @@ pub use transfer::{ pub use util::{dash_sdk_document_destroy, dash_sdk_document_handle_destroy}; // Re-export helper functions for use by submodules +pub(crate) use helpers::{build_document_from_properties, parse_document_properties_json}; pub use helpers::{ convert_gas_fees_paid_by, convert_state_transition_creation_options, convert_token_payment_info, }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift new file mode 100644 index 00000000000..72ccab1a759 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -0,0 +1,345 @@ +import DashSDKFFI +import Foundation + +/// A rule of a document type's `propertyConstraints` (meta-schema v3, protocol +/// version 14): a named condition every created or replaced document's +/// properties must meet. Consensus checks every rule, in name order, and +/// refuses a document breaking one with `DocumentPropertyConstraintViolatedError` +/// (code 10422); a refused state transition is still paid for. +/// +/// Rust parses the rules and reports them +/// (`dash_sdk_data_contract_get_property_constraints`); this type only carries +/// what it reports. The fields mirror wasm-dpp2's `DocumentPropertyConstraint` +/// key for key. +public struct DocumentPropertyConstraint: Equatable, Sendable { + /// The rule's name, its key in `propertyConstraints`. + public let name: String + + /// The rule exactly as the document type's schema declares it, as compact + /// JSON text with sorted keys (every operator object has a single key, so + /// sorting changes nothing a reader would notice). + public let ruleJSON: String + + /// Every property the rule reads, in declared order, a property read twice + /// listed twice. `$ownerId` is no property and is not listed: see + /// `readsOwner`. + public let reads: [PropertyConstraintRead] + + /// Whether the rule compares the document's owner, `$ownerId`: then a + /// transfer or a purchase, which changes the owner, is judged against it + /// too. + public let readsOwner: Bool + + public init(name: String, ruleJSON: String, reads: [PropertyConstraintRead], readsOwner: Bool) { + self.name = name + self.ruleJSON = ruleJSON + self.reads = reads + self.readsOwner = readsOwner + } + + /// `ruleJSON` indented for display, or `ruleJSON` itself should it not + /// parse back. + public var prettyRuleJSON: String { + guard let data = ruleJSON.data(using: .utf8), + let rule = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]), + let pretty = try? JSONSerialization.data( + withJSONObject: rule, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed]), + let text = String(data: pretty, encoding: .utf8) + else { + return ruleJSON + } + return text + } + + /// Decode the JSON array `dash_sdk_data_contract_get_property_constraints` + /// returns, keeping its order (name order). + public static func list(fromJSON json: String) throws -> [DocumentPropertyConstraint] { + guard let entries = try PropertyConstraintJSON.object(from: json) as? [Any] else { + throw SDKError.serializationError("propertyConstraints rules are not a JSON array") + } + return try entries.map { entry in + guard let rule = entry as? [String: Any], + let name = rule["name"] as? String, + let declaration = rule["rule"], + let reads = rule["reads"] as? [Any], + let readsOwner = DocumentTypedArray.jsonBool(rule["readsOwner"]) + else { + throw SDKError.serializationError("Malformed propertyConstraints rule: \(entry)") + } + return DocumentPropertyConstraint( + name: name, + ruleJSON: try PropertyConstraintJSON.compactText(declaration), + reads: try reads.map(PropertyConstraintRead.init(jsonEntry:)), + readsOwner: readsOwner + ) + } + } +} + +/// A property a `propertyConstraints` rule reads, and how it reads it. +public struct PropertyConstraintRead: Hashable, Sendable { + /// How a rule reads a property; the names are wasm-dpp2's + /// `PropertyConstraintReadKind`. + public enum Kind: Hashable, Sendable { + /// By its value, as an integer operand: an integer or boolean property. + case value + /// Only whether the document holds it, in `present` or `absent`. + case presence + /// By its value, compared with strings: a string property. + case text + /// By its value, compared with identifiers: an identifier property. + case identifier + /// A kind this build does not know, by its name. + case other(String) + + public init(name: String) { + switch name { + case "value": self = .value + case "presence": self = .presence + case "text": self = .text + case "identifier": self = .identifier + default: self = .other(name) + } + } + + /// The kind's name, as Rust reports it. + public var name: String { + switch self { + case .value: return "value" + case .presence: return "presence" + case .text: return "text" + case .identifier: return "identifier" + case let .other(name): return name + } + } + } + + /// The property's dotted path. + public let path: String + public let kind: Kind + + public init(path: String, kind: Kind) { + self.path = path + self.kind = kind + } + + init(jsonEntry entry: Any) throws { + guard let read = entry as? [String: Any], + let path = read["path"] as? String, + let kind = read["kind"] as? String + else { + throw SDKError.serializationError("Malformed propertyConstraints read: \(entry)") + } + self.init(path: path, kind: Kind(name: kind)) + } +} + +/// The first `propertyConstraints` rule a document breaks, as consensus would +/// report it in `DocumentPropertyConstraintViolatedError` (code 10422). +/// +/// Rust judges the document (`dash_sdk_data_contract_check_property_constraints`) +/// with the check consensus runs; this type only carries the verdict. The +/// fields mirror wasm-dpp2's `DocumentPropertyConstraintViolation`. +public struct PropertyConstraintViolation: Error, Equatable, Sendable, LocalizedError { + /// Why the rule is broken; the names are wasm-dpp2's + /// `PropertyConstraintViolationKind`. + public enum Kind: Hashable, Sendable { + /// The rule evaluates without a fault but does not hold. + case notMet + /// A value the rule reads or computes does not fit a 128-bit signed + /// integer. + case overflow + /// A `divide` or `modulo` by zero. + case divisionByZero + /// A `power` with a negative exponent. + case negativeExponent + /// A value the rule reads is not an integer. + case notAnInteger + /// A reason this build does not know, by its name. + case other(String) + + public init(name: String) { + switch name { + case "NotMet": self = .notMet + case "Overflow": self = .overflow + case "DivisionByZero": self = .divisionByZero + case "NegativeExponent": self = .negativeExponent + case "NotAnInteger": self = .notAnInteger + default: self = .other(name) + } + } + + /// The reason's name, as Rust reports it. + public var name: String { + switch self { + case .notMet: return "NotMet" + case .overflow: return "Overflow" + case .divisionByZero: return "DivisionByZero" + case .negativeExponent: return "NegativeExponent" + case .notAnInteger: return "NotAnInteger" + case let .other(name): return name + } + } + } + + /// The broken rule's name. + public let rule: String + public let violation: Kind + /// A readable reason, as in the consensus error's message. + public let message: String + + public init(rule: String, violation: Kind, message: String) { + self.rule = rule + self.violation = violation + self.message = message + } + + public var errorDescription: String? { + "The document breaks the propertyConstraints rule \"\(rule)\" (\(violation.name)): \(message)." + } + + /// Decode the JSON `dash_sdk_data_contract_check_property_constraints` + /// returns: `nil` for JSON `null`, when the document meets every rule. + public static func decode(fromJSON json: String) throws -> PropertyConstraintViolation? { + let object = try PropertyConstraintJSON.object(from: json) + if object is NSNull { + return nil + } + guard let violation = object as? [String: Any], + let rule = violation["rule"] as? String, + let kind = violation["violation"] as? String, + let message = violation["message"] as? String + else { + throw SDKError.serializationError("Malformed propertyConstraints violation: \(json)") + } + return PropertyConstraintViolation(rule: rule, violation: Kind(name: kind), message: message) + } +} + +// MARK: - FFI + +extension SDK { + /// The `propertyConstraints` rules of `documentType`, in name order (the + /// order consensus checks them in). Empty for a type declaring none, and for + /// every type while this SDK's protocol version is below 14. + /// + /// `serializedContract` is the contract's platform serialization, the bytes + /// kept beside a fetched contract (`PersistentDataContract.binarySerialization`). + /// Rust reads it at this SDK's protocol version. Bridges + /// `dash_sdk_data_contract_get_property_constraints`. + /// + /// - Throws: `SDKError.notFound` for a document type the contract does not + /// declare, `SDKError.serializationError` for bytes that are not a + /// contract. + public func documentPropertyConstraints( + serializedContract: Data, + documentType: String + ) throws -> [DocumentPropertyConstraint] { + guard let handle else { + throw SDKError.invalidState("SDK not initialized") + } + let result = serializedContract.withUnsafeBytes { contract in + documentType.withCString { documentType in + dash_sdk_data_contract_get_property_constraints( + handle, + contract.bindMemory(to: UInt8.self).baseAddress, + UInt(contract.count), + documentType + ) + } + } + return try DocumentPropertyConstraint.list(fromJSON: PropertyConstraintJSON.text(of: result)) + } + + /// The first `propertyConstraints` rule a document to create would break, + /// or `nil` when it meets them all (always so while this SDK's protocol + /// version is below 14). + /// + /// `propertiesJSON` is the properties JSON the document would be created + /// with (the string handed to `ManagedPlatformWallet.createDocument`) and + /// `ownerId` the 32-byte identity that would own it, which `$ownerId` + /// reads. Rust builds the document the create path builds and judges it + /// with the check consensus runs; nothing but the rules is checked. + /// `serializedContract` is as for `documentPropertyConstraints`. Bridges + /// `dash_sdk_data_contract_check_property_constraints`. + /// + /// - Throws: `SDKError.invalidParameter` for an owner id that is not 32 + /// bytes or properties that are not a JSON object, `SDKError.notFound` for + /// a document type the contract does not declare, + /// `SDKError.serializationError` for bytes that are not a contract. + public func checkDocumentPropertyConstraints( + serializedContract: Data, + documentType: String, + propertiesJSON: String, + ownerId: Identifier + ) throws -> PropertyConstraintViolation? { + guard let handle else { + throw SDKError.invalidState("SDK not initialized") + } + // The FFI reads exactly 32 bytes behind the pointer + guard ownerId.count == 32 else { + throw SDKError.invalidParameter("Owner ID must be 32 bytes, got \(ownerId.count)") + } + let result = serializedContract.withUnsafeBytes { contract in + ownerId.withUnsafeBytes { owner in + documentType.withCString { documentType in + propertiesJSON.withCString { propertiesJSON in + dash_sdk_data_contract_check_property_constraints( + handle, + contract.bindMemory(to: UInt8.self).baseAddress, + UInt(contract.count), + documentType, + propertiesJSON, + owner.bindMemory(to: UInt8.self).baseAddress + ) + } + } + } + } + return try PropertyConstraintViolation.decode(fromJSON: PropertyConstraintJSON.text(of: result)) + } +} + +// MARK: - JSON readers + +enum PropertyConstraintJSON { + /// The C string a `DashSDKResult` carries, freeing it, or the error it + /// carries as an `SDKError` (keeping its code), freeing that. + static func text(of result: DashSDKResult) throws -> String { + if let error = result.error { + let sdkError = SDKError.fromDashSDKError(error.pointee) + dash_sdk_error_free(error) + throw sdkError + } + guard let data = result.data else { + throw SDKError.internalError("No data returned") + } + let text = String(cString: data.assumingMemoryBound(to: CChar.self)) + dash_sdk_string_free(data.assumingMemoryBound(to: CChar.self)) + return text + } + + /// The JSON value `text` holds, `NSNull` for `null`. + static func object(from text: String) throws -> Any { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + else { + throw SDKError.serializationError("Not JSON: \(text)") + } + return object + } + + /// `value` as compact JSON text with sorted keys. + static func compactText(_ value: Any) throws -> String { + guard JSONSerialization.isValidJSONObject([value]), + let data = try? JSONSerialization.data( + withJSONObject: value, + options: [.sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed]), + let text = String(data: data, encoding: .utf8) + else { + throw SDKError.serializationError("Not a JSON value: \(value)") + } + return text + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index 2761654c802..3b79011b461 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -152,6 +152,60 @@ extension PersistentDocumentType { DocumentTypedArray.named(name, inDocumentTypeSchema: schema) } + /// Whether the persisted schema carries the `propertyConstraints` keyword + /// (protocol version 14). Says nothing about the rules themselves: those + /// are read by `propertyConstraints(using:)`, which parses them in Rust. + public var declaresPropertyConstraints: Bool { + schema?["propertyConstraints"] != nil + } + + /// The type's `propertyConstraints` rules, in name order: what + /// `SDK.documentPropertyConstraints(serializedContract:documentType:)` + /// reads from the parent contract's stored platform serialization + /// (`PersistentDataContract.binarySerialization`) at `sdk`'s protocol + /// version. Nothing is stored for them: like `immutability`, they are + /// derived on demand, so the model's entity hash does not move. + /// + /// - Throws: `SDKError.invalidState` when the parent contract has no + /// stored serialization, or what the SDK call throws. + public func propertyConstraints(using sdk: SDK) throws -> [DocumentPropertyConstraint] { + try sdk.documentPropertyConstraints( + serializedContract: storedContractSerialization(), + documentType: name + ) + } + + /// The first `propertyConstraints` rule a document of this type, created + /// with `propertiesJSON` and owned by `ownerId`, would break, or `nil` + /// when it meets them all: what + /// `SDK.checkDocumentPropertyConstraints(serializedContract:documentType:propertiesJSON:ownerId:)` + /// reports for the parent contract's stored platform serialization. + /// + /// - Throws: `SDKError.invalidState` when the parent contract has no + /// stored serialization, or what the SDK call throws. + public func propertyConstraintViolation( + propertiesJSON: String, + ownerId: Identifier, + using sdk: SDK + ) throws -> PropertyConstraintViolation? { + try sdk.checkDocumentPropertyConstraints( + serializedContract: storedContractSerialization(), + documentType: name, + propertiesJSON: propertiesJSON, + ownerId: ownerId + ) + } + + /// The parent contract's stored platform serialization. + private func storedContractSerialization() throws -> Data { + guard let serialization = dataContract?.binarySerialization, !serialization.isEmpty else { + throw SDKError.invalidState( + "The data contract \(contractIdBase58) has no stored serialization; download it again" + ) + } + return serialization + } + public var documentCount: Int { documents?.count ?? 0 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 731d741e047..7c6f83000e6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -9,17 +9,27 @@ struct DocumentTypeDetailsView: View { @Environment(\.dismiss) var dismiss @State private var expandedIndices: Set = [] @State private var showingCreateDocument = false + /// The type's `propertyConstraints` rules as Rust reads them; `nil` until + /// loaded. + @State private var propertyConstraints: [DocumentPropertyConstraint]? + @State private var propertyConstraintsError: String? var body: some View { List { newDocumentSection documentInfoSection documentSettingsSection + propertyConstraintsSection documentIndexesSection documentPropertiesSection } .navigationTitle(documentType.name) .navigationBarTitleDisplayMode(.inline) + // Re-read when the SDK learns the network's protocol version: the + // rules are parsed at that version, and none exist below 14. + .task(id: appState.platformProtocolVersion) { + loadPropertyConstraints() + } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { @@ -181,6 +191,57 @@ struct DocumentTypeDetailsView: View { } } + /// Protocol version 14: named rules every created or replaced document + /// must meet, checked in name order. Rust parses them from the stored + /// contract; this section only shows what it reports. + @ViewBuilder + private var propertyConstraintsSection: some View { + if let error = propertyConstraintsError { + Section("Property Constraints") { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + } + } else if let rules = propertyConstraints, !rules.isEmpty { + Section { + ForEach(rules, id: \.name) { rule in + PropertyConstraintRowView(rule: rule) + } + } header: { + Text("Property Constraints (\(rules.count))") + } footer: { + Text("Every created or replaced document must meet each rule, checked in name order. A document breaking one is refused (error 10422) and the fee is still charged.") + } + } else if propertyConstraints != nil, documentType.declaresPropertyConstraints { + Section("Property Constraints") { + Text("The schema declares propertyConstraints, but the network's protocol version does not enforce them (they take effect at protocol version 14).") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private func loadPropertyConstraints() { + // A schema without the keyword has no rules to read + guard documentType.declaresPropertyConstraints else { + propertyConstraints = [] + propertyConstraintsError = nil + return + } + guard let sdk = appState.sdk else { + propertyConstraints = nil + propertyConstraintsError = "Connect to a network to read the property constraints." + return + } + do { + propertyConstraints = try documentType.propertyConstraints(using: sdk) + propertyConstraintsError = nil + } catch { + propertyConstraints = nil + propertyConstraintsError = "Could not read the property constraints: \(error.localizedDescription)" + } + } + @ViewBuilder private var documentIndexesSection: some View { if let indices = documentType.indices, !indices.isEmpty { @@ -389,6 +450,64 @@ struct ExpandableIndexRowView: View { } } +/// One `propertyConstraints` rule: its name, the rule as declared, what it +/// reads, and whether an owner change is judged against it too. +struct PropertyConstraintRowView: View { + let rule: DocumentPropertyConstraint + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(rule.name) + .font(.headline) + Spacer() + if rule.readsOwner { + Text("$ownerId") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.purple.opacity(0.2)) + .foregroundColor(.purple) + .cornerRadius(4) + } + } + + ScrollView(.horizontal, showsIndicators: false) { + Text(rule.prettyRuleJSON) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: true, vertical: true) + } + + if !readsText.isEmpty { + Text("Reads: \(readsText)") + .font(.caption) + .foregroundColor(.secondary) + } + + if rule.readsOwner { + Label( + "Reads $ownerId, the document's owner: transfers and purchases are judged against this rule too.", + systemImage: "person.crop.circle.badge.checkmark" + ) + .font(.caption2) + .foregroundColor(.purple) + } + } + .padding(.vertical, 4) + .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name)") + } + + /// Each property the rule reads with how it reads it, repeats dropped. + private var readsText: String { + var seen = Set() + return rule.reads + .filter { seen.insert($0).inserted } + .map { "\($0.path) (\($0.kind.name))" } + .joined(separator: ", ") + } +} + struct PropertyRowView: View { let propertyName: String let propertyData: Any diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index c157d226459..478cc8b8209 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1498,6 +1498,7 @@ struct CreateDocumentView: View { private struct SubmitError: Identifiable { let id = UUID() + var title = "Create failed" let message: String } @@ -1532,7 +1533,7 @@ struct CreateDocumentView: View { .interactiveDismissDisabled(isSubmitting) .alert(item: $submitError) { err in Alert( - title: Text("Create failed"), + title: Text(err.title), message: Text(err.message), dismissButton: .default(Text("OK")) ) @@ -1764,6 +1765,21 @@ struct CreateDocumentView: View { return } + // Protocol version 14: consensus refuses a document breaking one of + // its type's propertyConstraints rules (error 10422) and still charges + // for the transition, so judge it first with the same Rust check. + if let violation = propertyConstraintViolation( + of: docType, + propertiesJSON: propertiesJSON, + ownerId: ownerIdentity.identityId + ) { + submitError = .init( + title: "Not sent: a property constraint is broken", + message: "Rule: \(violation.rule)\nViolation: \(violation.violation.name)\nReason: \(violation.message)" + ) + return + } + isSubmitting = true // Fresh `KeychainSigner` per submit pass, same as // `TransferCreditsView` / `RegisterNameView`: the trampoline @@ -1817,6 +1833,28 @@ struct CreateDocumentView: View { } } + /// The first propertyConstraints rule the document would break, or `nil` + /// when it meets them all or has none. A check that cannot run (no SDK, + /// no stored contract serialization) blocks nothing: consensus judges the + /// document either way. + private func propertyConstraintViolation( + of docType: PersistentDocumentType, + propertiesJSON: String, + ownerId: Identifier + ) -> PropertyConstraintViolation? { + guard docType.declaresPropertyConstraints, let sdk = appState.sdk else { return nil } + do { + return try docType.propertyConstraintViolation( + propertiesJSON: propertiesJSON, + ownerId: ownerId, + using: sdk + ) + } catch { + print("⚠️ propertyConstraints pre-check could not run: \(error.localizedDescription)") + return nil + } + } + /// Persist the confirmed document so it shows up in the Documents /// list (DOC-01). Persistence stays in Swift per /// `swift-sdk/CLAUDE.md`; the broadcast itself happened in Rust. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift new file mode 100644 index 00000000000..ee29cda741c --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift @@ -0,0 +1,381 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Coverage for the protocol-version-14 `propertyConstraints` bridge: the +/// decoding of what `dash_sdk_data_contract_get_property_constraints` and +/// `dash_sdk_data_contract_check_property_constraints` return, and the +/// wrappers' round trip through the FFI. +/// +/// Rust parses and evaluates the rules (rs-sdk-ffi's own tests cover every +/// rule family and violation); the Swift side only marshals, so these tests pin +/// the JSON shapes, which match wasm-dpp2's key for key, and the marshalling. +/// The round trips run on the FFI mock SDK, which sits at a protocol version +/// below 14, where no document type carries a rule. +@MainActor +final class DocumentPropertyConstraintsTests: XCTestCase { + + /// The rules of an `offer` type as the FFI reports them (rs-sdk-ffi's + /// `should_list_every_rule_in_name_order_with_what_it_reads`), abridged to + /// three rules. + private let rulesJSON = """ + [ + { + "name": "closedNeedsClosedAt", + "readsOwner": false, + "reads": [ + { "kind": "text", "path": "status" }, + { "kind": "presence", "path": "closedAt" } + ], + "rule": { + "anyOf": [ + { "notEqual": ["status", { "const": "closed" }] }, + { "present": "closedAt" } + ] + } + }, + { + "name": "perUnitFee", + "readsOwner": false, + "reads": [ + { "kind": "value", "path": "price" }, + { "kind": "value", "path": "fee" } + ], + "rule": { "greaterThanOrEqual": [{ "divide": ["price", "fee"] }, 1] } + }, + { + "name": "sellerIsOwner", + "readsOwner": true, + "reads": [ + { "kind": "presence", "path": "sellerId" }, + { "kind": "identifier", "path": "sellerId" } + ], + "rule": { + "anyOf": [{ "absent": "sellerId" }, { "equal": ["sellerId", "$ownerId"] }] + } + } + ] + """ + + /// The platform serialization of a contract created at protocol version + /// 13, owned by `[7; 32]`, declaring one `note` type with a string + /// `message` and no rules (generated by rs-sdk-ffi's + /// `serialize_to_bytes_with_platform_version`). It reads at every later + /// protocol version too. + private let noteContractHex = + "013ac40651a4bcb91c3f5c1e458a1c95e48dc2c8259003dd580cab11a5f02ed4850100000000010100000101070707070707" + + "07070707070707070707070707070707070707070707070707070001046e6f7465160312047479706512066f626a65637412" + + "0a70726f70657274696573160112076d65737361676516031204747970651206737472696e6712096d61784c656e67746805" + + "801208706f736974696f6e050012146164646974696f6e616c50726f70657274696573130000000000000000000000" + + private let ownerId = Data(repeating: 7, count: 32) + + // MARK: - Rules + + func testRulesDecodeInOrderWithWhatTheyRead() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: rulesJSON) + + XCTAssertEqual(rules.map(\.name), ["closedNeedsClosedAt", "perUnitFee", "sellerIsOwner"]) + XCTAssertEqual( + rules[0].reads, + [ + PropertyConstraintRead(path: "status", kind: .text), + PropertyConstraintRead(path: "closedAt", kind: .presence) + ] + ) + XCTAssertEqual(rules[1].reads.map(\.kind), [.value, .value]) + XCTAssertEqual(rules[2].reads.map(\.kind), [.presence, .identifier]) + XCTAssertEqual(rules.map(\.readsOwner), [false, false, true]) + } + + /// The rule is kept as the JSON the schema declares, compact with sorted + /// keys: array order, and so operand order, is untouched. + func testRuleIsKeptAsDeclaredJSON() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: rulesJSON) + + XCTAssertEqual( + rules[0].ruleJSON, + #"{"anyOf":[{"notEqual":["status",{"const":"closed"}]},{"present":"closedAt"}]}"# + ) + XCTAssertEqual(rules[1].ruleJSON, #"{"greaterThanOrEqual":[{"divide":["price","fee"]},1]}"#) + XCTAssertEqual( + rules[2].ruleJSON, + #"{"anyOf":[{"absent":"sellerId"},{"equal":["sellerId","$ownerId"]}]}"# + ) + } + + func testPrettyRuleJSONIndentsTheSameRule() throws { + let rule = try XCTUnwrap(DocumentPropertyConstraint.list(fromJSON: rulesJSON).first) + + let pretty = rule.prettyRuleJSON + XCTAssertTrue(pretty.contains("\n"), pretty) + let reparsed = try JSONSerialization.jsonObject(with: Data(pretty.utf8)) as? NSDictionary + let original = try JSONSerialization.jsonObject(with: Data(rule.ruleJSON.utf8)) as? NSDictionary + XCTAssertEqual(reparsed, original) + } + + func testNoRulesDecodeToAnEmptyList() throws { + XCTAssertEqual(try DocumentPropertyConstraint.list(fromJSON: "[]"), []) + } + + /// A kind added by a later protocol version is kept by name rather than + /// failing the whole list. + func testUnknownReadKindIsKeptByName() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "rule": { "present": "a" }, "readsOwner": false, + "reads": [{ "path": "a", "kind": "somethingNew" }] }] + """) + + XCTAssertEqual(rules.first?.reads.first?.kind, .other("somethingNew")) + XCTAssertEqual(rules.first?.reads.first?.kind.name, "somethingNew") + } + + func testMalformedRulesAreRefused() { + let malformed = [ + "not json", + #"{"name": "r"}"#, + #"[{"name": "r", "rule": {"present": "a"}, "reads": []}]"#, + // A number is not a boolean, although NSNumber would cast it + #"[{"name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": 1}]"#, + #"[{"name": "r", "rule": {"present": "a"}, "reads": [{"path": "a"}], "readsOwner": false}]"# + ] + for json in malformed { + XCTAssertThrowsError(try DocumentPropertyConstraint.list(fromJSON: json), json) { error in + guard case SDKError.serializationError = error else { + return XCTFail("\(json): expected a serialization error, got \(error)") + } + } + } + } + + // MARK: - Violations + + func testViolationDecodes() throws { + let violation = try XCTUnwrap(PropertyConstraintViolation.decode(fromJSON: """ + { "rule": "perUnitFee", "violation": "DivisionByZero", "message": "it divides by zero" } + """)) + + XCTAssertEqual( + violation, + PropertyConstraintViolation( + rule: "perUnitFee", + violation: .divisionByZero, + message: "it divides by zero" + ) + ) + XCTAssertEqual( + violation.localizedDescription, + "The document breaks the propertyConstraints rule \"perUnitFee\" (DivisionByZero): it divides by zero." + ) + } + + func testEveryViolationNameRoundTrips() { + let names = ["NotMet", "Overflow", "DivisionByZero", "NegativeExponent", "NotAnInteger"] + let kinds: [PropertyConstraintViolation.Kind] = [ + .notMet, .overflow, .divisionByZero, .negativeExponent, .notAnInteger + ] + XCTAssertEqual(names.map(PropertyConstraintViolation.Kind.init(name:)), kinds) + XCTAssertEqual(kinds.map(\.name), names) + XCTAssertEqual(PropertyConstraintViolation.Kind(name: "Later"), .other("Later")) + } + + func testNullMeansEveryRuleHolds() throws { + XCTAssertNil(try PropertyConstraintViolation.decode(fromJSON: "null")) + } + + func testMalformedViolationsAreRefused() { + for json in ["", "[]", #"{"rule": "r", "violation": "NotMet"}"#] { + XCTAssertThrowsError(try PropertyConstraintViolation.decode(fromJSON: json), json) + } + } + + // MARK: - FFI round trips + + func testWrappersRoundTripThroughTheFFI() throws { + let sdk = try mockSDK() + let contract = try noteContract() + + XCTAssertEqual( + try sdk.documentPropertyConstraints(serializedContract: contract, documentType: "note"), + [] + ) + XCTAssertNil( + try sdk.checkDocumentPropertyConstraints( + serializedContract: contract, + documentType: "note", + propertiesJSON: #"{"message":"hi"}"#, + ownerId: ownerId + ) + ) + } + + func testFFIErrorsKeepTheirCodes() throws { + let sdk = try mockSDK() + let contract = try noteContract() + + assertThrows( + try sdk.documentPropertyConstraints(serializedContract: contract, documentType: "letter") + ) { error in + if case SDKError.notFound = error { return true } + return false + } + assertThrows( + try sdk.documentPropertyConstraints(serializedContract: Data([0xFF, 0x00, 0x13]), documentType: "note") + ) { error in + if case SDKError.serializationError = error { return true } + return false + } + assertThrows( + try sdk.documentPropertyConstraints(serializedContract: Data(), documentType: "note") + ) { error in + if case SDKError.invalidParameter = error { return true } + return false + } + assertThrows( + try sdk.checkDocumentPropertyConstraints( + serializedContract: contract, + documentType: "note", + propertiesJSON: "[1]", + ownerId: ownerId + ) + ) { error in + if case SDKError.invalidParameter = error { return true } + return false + } + } + + /// The FFI reads 32 bytes behind the owner pointer, so a shorter id is + /// refused before the call. + func testOwnerIdMustBe32Bytes() throws { + let sdk = try mockSDK() + + assertThrows( + try sdk.checkDocumentPropertyConstraints( + serializedContract: try noteContract(), + documentType: "note", + propertiesJSON: "{}", + ownerId: Data(repeating: 7, count: 20) + ) + ) { error in + if case SDKError.invalidParameter = error { return true } + return false + } + } + + // MARK: - PersistentDocumentType + + func testDocumentTypeReadsThroughItsStoredContract() throws { + let sdk = try mockSDK() + let stored = try persistNoteType(binarySerialization: try noteContract()) + let docType = stored.documentType + + XCTAssertFalse(docType.declaresPropertyConstraints) + XCTAssertEqual(try docType.propertyConstraints(using: sdk), []) + XCTAssertNil( + try docType.propertyConstraintViolation( + propertiesJSON: #"{"message":"hi"}"#, + ownerId: ownerId, + using: sdk + ) + ) + withExtendedLifetime(stored.context) {} + } + + func testDocumentTypeWithoutAStoredContractCannotBeRead() throws { + let sdk = try mockSDK() + let stored = try persistNoteType(binarySerialization: nil) + + assertThrows(try stored.documentType.propertyConstraints(using: sdk)) { error in + if case SDKError.invalidState = error { return true } + return false + } + withExtendedLifetime(stored.context) {} + } + + func testDeclaresPropertyConstraintsReadsTheKeyword() { + let schema: [String: Any] = [ + "type": "object", + "propertyConstraints": ["r": ["present": "a"]] + ] + let docType = PersistentDocumentType( + contractId: Data(repeating: 1, count: 32), + name: "offer", + schemaJSON: (try? JSONSerialization.data(withJSONObject: schema)) ?? Data(), + propertiesJSON: Data("{}".utf8) + ) + + XCTAssertTrue(docType.declaresPropertyConstraints) + } + + // MARK: - Helpers + + private func mockSDK() throws -> SDK { + SDK.initialize() + return try SDK(mockVectorsDirectory: nil) + } + + private func noteContract() throws -> Data { + let contract = try XCTUnwrap(Data(hexString: noteContractHex)) + // `Data(hexString:)` ignores a trailing odd digit: pin the whole fixture + XCTAssertEqual(contract.count * 2, noteContractHex.count) + return contract + } + + /// A persisted document type and the context holding it, which the caller + /// keeps alive while it reads the type's relationships. + private struct StoredDocumentType { + let documentType: PersistentDocumentType + let context: ModelContext + } + + /// Persist the `note` contract and its type the way a download does: the + /// contract row carrying `binarySerialization`, and the parser writing the + /// type row. + private func persistNoteType(binarySerialization: Data?) throws -> StoredDocumentType { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + let contractId = Data(repeating: 0xC3, count: 32) + + let contract = PersistentDataContract( + id: contractId, + name: "Notes", + serializedContract: Data(), + network: .testnet + ) + contract.binarySerialization = binarySerialization + context.insert(contract) + try context.save() + + try DataContractParser.parseDataContract( + contractData: [ + "documents": [ + "note": [ + "type": "object", + "properties": ["message": ["type": "string", "maxLength": 64, "position": 0]], + "additionalProperties": false + ] + ] + ], + contractId: contractId, + modelContext: context + ) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == contractId } + ) + let docType = try XCTUnwrap(try context.fetch(descriptor).first) + return StoredDocumentType(documentType: docType, context: context) + } + + private func assertThrows( + _ expression: @autoclosure () throws -> T, + file: StaticString = #filePath, + line: UInt = #line, + matching matches: (Error) -> Bool + ) { + XCTAssertThrowsError(try expression(), file: file, line: line) { error in + XCTAssertTrue(matches(error), "unexpected error: \(error)", file: file, line: line) + } + } +} From a5fac13f64a9ccc05a96e87c7a7a00b201a28afe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 27 Sep 2026 19:57:29 +0700 Subject: [PATCH 2/2] test(sdk): read the pre-v14 property constraints fixture from v14 bytes The workspace build compiles dpp's JSON schema validation in, and the protocol version 13 meta-schema refuses the propertyConstraints keyword, so the fixture could not be created at 13. The test now reads the bytes a network at 14 returns with an SDK still at 13, the case it is meant to cover. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-ffi/src/data_contract/property_constraints.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index c04403a96bb..43e9ed8adb5 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -751,12 +751,14 @@ mod tests { /// Parsers before protocol version 14 ignore the keyword, so an SDK at such /// a version reports no rule and no violation: exactly what consensus - /// enforced there. + /// enforced there. The bytes are those a network at 14 returns; the + /// contract cannot be created at 13, whose meta-schema refuses the keyword + /// when JSON schema validation is compiled in. #[test] fn should_report_no_rules_below_protocol_version_14() { let platform_version = PlatformVersion::get(13).expect("protocol version 13"); let sdk = sdk_handle(platform_version); - let contract = serialized_contract(platform_version); + let contract = serialized_contract(PlatformVersion::latest()); let rules = rules_of(sdk, &contract, "offer"); let violation = check(