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..43e9ed8adb5 --- /dev/null +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -0,0 +1,852 @@ +//! `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. 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(PlatformVersion::latest()); + + 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) + } + } +}