diff --git a/packages/rs-sdk-ffi/src/document/util.rs b/packages/rs-sdk-ffi/src/document/util.rs index 33b6cc69bf3..75d8c5ab5be 100644 --- a/packages/rs-sdk-ffi/src/document/util.rs +++ b/packages/rs-sdk-ffi/src/document/util.rs @@ -1,7 +1,13 @@ use crate::sdk::SDKWrapper; use crate::{DashSDKError, DashSDKErrorCode, DocumentHandle, FFIError, SDKHandle}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dash_sdk::dpp::document::{Document, DocumentV0Setters}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::Identifier; +use drive_proof_verifier::ContextProvider; +use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; @@ -66,24 +72,53 @@ pub unsafe extern "C" fn dash_sdk_document_free(handle: *mut DocumentHandle) { /// Set document properties from JSON /// +/// The values are sanitized against the document type the way +/// `dash_sdk_document_create` sanitizes them: base58 or hex strings become +/// identifiers, hex or base64 strings become byte arrays and integers narrow +/// to their declared width, typed array elements included. The data contract +/// comes from the trusted context provider, the same lookup +/// `dash_sdk_document_replace_on_platform` makes. On any error the document +/// is left unchanged. +/// /// # Safety -/// - `document_handle` and `properties_json` must be valid, non-null pointers. -/// - `properties_json` must point to a NUL-terminated C string valid for the duration of the call. +/// - `sdk_handle`, `document_handle`, `data_contract_id`, `document_type_name` and +/// `properties_json` must be valid, non-null pointers. +/// - `data_contract_id` (base58 encoded), `document_type_name` and `properties_json` must point +/// to NUL-terminated C strings valid for the duration of the call. /// - Returns an error pointer on failure; caller must free with `dash_sdk_error_free`. #[no_mangle] pub unsafe extern "C" fn dash_sdk_document_set_properties( + sdk_handle: *const SDKHandle, document_handle: *mut DocumentHandle, + data_contract_id: *const c_char, + document_type_name: *const c_char, properties_json: *const c_char, ) -> *mut DashSDKError { - if document_handle.is_null() || properties_json.is_null() { + if sdk_handle.is_null() + || document_handle.is_null() + || data_contract_id.is_null() + || document_type_name.is_null() + || properties_json.is_null() + { return Box::into_raw(Box::new(DashSDKError::new( DashSDKErrorCode::InvalidParameter, "Invalid parameters".to_string(), ))); } + let wrapper = &*(sdk_handle as *const SDKWrapper); let document = &mut *(document_handle as *mut Document); + let contract_id_str = match CStr::from_ptr(data_contract_id).to_str() { + Ok(s) => s, + Err(e) => return Box::into_raw(Box::new(FFIError::from(e).into())), + }; + + let document_type_name_str = match CStr::from_ptr(document_type_name).to_str() { + Ok(s) => s, + Err(e) => return Box::into_raw(Box::new(FFIError::from(e).into())), + }; + let properties_str = match CStr::from_ptr(properties_json).to_str() { Ok(s) => s, Err(e) => { @@ -106,10 +141,10 @@ pub unsafe extern "C" fn dash_sdk_document_set_properties( }; // Convert Value to BTreeMap if it's an object - let properties_map = match properties_value { + let mut properties_map = match properties_value { Value::Map(vec_map) => { // Convert Vec<(Value, Value)> to BTreeMap - let mut btree_map = std::collections::BTreeMap::new(); + let mut btree_map = BTreeMap::new(); for (key, value) in vec_map { let key_str = match key { Value::Text(s) => s, @@ -132,8 +167,246 @@ pub unsafe extern "C" fn dash_sdk_document_set_properties( } }; + if let Err(e) = sanitize_properties_for_document_type( + wrapper, + contract_id_str, + document_type_name_str, + &mut properties_map, + ) { + return Box::into_raw(Box::new(e.into())); + } + // Set the properties on the document document.set_properties(properties_map); std::ptr::null_mut() } + +/// Sanitizes `properties` against the `document_type_name` type of the +/// contract the trusted context provider holds under `data_contract_id`. +fn sanitize_properties_for_document_type( + wrapper: &SDKWrapper, + data_contract_id: &str, + document_type_name: &str, + properties: &mut BTreeMap, +) -> Result<(), FFIError> { + let contract_id = Identifier::from_string(data_contract_id, Encoding::Base58) + .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; + + let provider = wrapper.trusted_provider.as_ref().ok_or_else(|| { + FFIError::InternalError("No trusted context provider configured".to_string()) + })?; + + let data_contract = provider + .get_data_contract(&contract_id, wrapper.sdk.version()) + .map_err(|e| { + FFIError::InternalError(format!("Failed to get contract from context: {}", e)) + })? + .ok_or_else(|| { + FFIError::InternalError(format!( + "Contract {} not found in trusted context", + data_contract_id + )) + })?; + + let document_type = data_contract + .document_type_borrowed_for_name(document_type_name) + .map_err(|e| FFIError::InternalError(format!("Failed to get document type: {}", e)))?; + + document_type.sanitize_document_properties(properties); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash_sdk_error_free; + use crate::test_utils::test_utils::destroy_mock_sdk_handle; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::data_contract::DataContractFactory; + use dash_sdk::dpp::document::{DocumentV0, DocumentV0Getters}; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::prelude::DataContract; + use dash_sdk::dpp::version::PlatformVersion; + use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; + use std::ffi::CString; + use std::num::NonZeroUsize; + use std::sync::Arc; + + /// A contract with one `note` type: an identifier, a byte array and a + /// typed array of identifiers (protocol version 14). + fn note_contract() -> DataContract { + let documents = platform_value!({ + "note": { + "type": "object", + "properties": { + "author": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "payload": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 64, + "position": 1 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 2 + } + }, + "additionalProperties": false + } + }); + + let factory = DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("factory for the latest protocol version"); + factory + .create_with_value_config(Identifier::new([1; 32]), 1, documents, None, None) + .expect("note contract") + .data_contract() + .clone() + } + + /// A mock SDK handle whose trusted context provider serves `contracts`. + /// The provider's quorum URL is an address literal, so nothing resolves + /// or connects. + fn sdk_handle_with_known_contracts(contracts: Vec) -> *mut SDKHandle { + let provider = TrustedHttpContextProvider::new_with_url( + Network::Regtest, + "http://127.0.0.1:22444".to_string(), + NonZeroUsize::new(1).expect("non-zero cache size"), + ) + .expect("offline trusted context provider") + .with_known_contracts(contracts); + let mut wrapper = SDKWrapper::new_mock(); + wrapper.trusted_provider = Some(Arc::new(provider)); + Box::into_raw(Box::new(wrapper)) as *mut SDKHandle + } + + fn fetched_note() -> Document { + Document::V0(DocumentV0 { + id: Identifier::new([2; 32]), + owner_id: Identifier::new([1; 32]), + properties: BTreeMap::new(), + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + contract_version: None, + }) + } + + #[test] + fn should_store_identifier_and_byte_properties_decoded_from_strings() { + let contract = note_contract(); + let contract_id = CString::new(contract.id().to_string(Encoding::Base58)) + .expect("no NUL in the contract id"); + let document_type_name = CString::new("note").expect("no NUL in the type name"); + let author = Identifier::new([7; 32]); + let first_member = Identifier::new([8; 32]); + let second_member = Identifier::new([9; 32]); + let properties_json = CString::new(format!( + r#"{{"author":"{}","payload":"deadbeef","members":["{}","{}"]}}"#, + author.to_string(Encoding::Base58), + first_member.to_string(Encoding::Base58), + second_member.to_string(Encoding::Base58), + )) + .expect("no NUL in the JSON"); + + let sdk_handle = sdk_handle_with_known_contracts(vec![contract]); + let document_handle = Box::into_raw(Box::new(fetched_note())) as *mut DocumentHandle; + let error = unsafe { + dash_sdk_document_set_properties( + sdk_handle, + document_handle, + contract_id.as_ptr(), + document_type_name.as_ptr(), + properties_json.as_ptr(), + ) + }; + assert!(error.is_null(), "set_properties failed"); + + let document = unsafe { Box::from_raw(document_handle as *mut Document) }; + destroy_mock_sdk_handle(sdk_handle); + let properties = document.properties(); + assert_eq!( + properties.get("author"), + Some(&Value::Identifier(author.to_buffer())) + ); + assert_eq!( + properties.get("payload"), + Some(&Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])) + ); + assert_eq!( + properties.get("members"), + Some(&Value::Array(vec![ + Value::Identifier(first_member.to_buffer()), + Value::Identifier(second_member.to_buffer()), + ])) + ); + } + + #[test] + fn should_leave_the_document_unchanged_when_the_document_type_cannot_be_resolved() { + let contract = note_contract(); + let known_contract_id = CString::new(contract.id().to_string(Encoding::Base58)) + .expect("no NUL in the contract id"); + let unknown_contract_id = + CString::new(Identifier::new([3; 32]).to_string(Encoding::Base58)) + .expect("no NUL in the contract id"); + let note = CString::new("note").expect("no NUL in the type name"); + let unknown_type = CString::new("letter").expect("no NUL in the type name"); + let properties_json = + CString::new(r#"{"payload":"deadbeef"}"#).expect("no NUL in the JSON"); + + let sdk_handle = sdk_handle_with_known_contracts(vec![contract]); + for (contract_id, document_type_name) in [ + (&unknown_contract_id, ¬e), + (&known_contract_id, &unknown_type), + ] { + let mut original = fetched_note(); + original.set("payload", Value::Bytes(vec![1, 2, 3])); + let document_handle = Box::into_raw(Box::new(original.clone())) as *mut DocumentHandle; + + let error = unsafe { + dash_sdk_document_set_properties( + sdk_handle, + document_handle, + contract_id.as_ptr(), + document_type_name.as_ptr(), + properties_json.as_ptr(), + ) + }; + + assert!(!error.is_null(), "an unresolvable type must be refused"); + assert_eq!(unsafe { (*error).code }, DashSDKErrorCode::InternalError); + unsafe { dash_sdk_error_free(error) }; + let document = unsafe { Box::from_raw(document_handle as *mut Document) }; + assert_eq!(*document, original); + } + destroy_mock_sdk_handle(sdk_handle); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index 7705837b151..f6feefd05b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -716,9 +716,28 @@ extension SDK { // 2. Update the document properties // Use pre-serialized JSON to avoid capturing non-Sendable value types + // The FFI decodes base58 identifiers and hex/base64 bytes against the document type. let propertiesJson = propertiesJsonPre - _ = propertiesJson.withCString { propsCStr in - dash_sdk_document_set_properties(OpaquePointer(documentHandle), propsCStr) + let setPropertiesError = contractId.withCString { contractIdCStr in + documentType.withCString { docTypeCStr in + propertiesJson.withCString { propsCStr in + dash_sdk_document_set_properties( + handle, + OpaquePointer(documentHandle), + contractIdCStr, + docTypeCStr, + propsCStr + ) + } + } + } + + if let error = setPropertiesError { + let errorString = String(cString: error.pointee.message) + dash_sdk_error_free(error) + print("❌ [DOCUMENT REPLACE] Failed to set document properties: \(errorString)") + continuation.resume(throwing: SDKError.internalError("Failed to set document properties: \(errorString)")) + return } // 3. Get appropriate key for signing