From 970b63a8343cbfab0dc2ec4698d07231b478888c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 10:06:06 +0700 Subject: [PATCH 1/2] feat(platform)!: DashPay contact requests declare their checks (PV14) DashPay v2's contactRequest now states in its schema what consensus checks, using the PV14 keywords, instead of a hand-written data trigger: - toUserId: distinctFrom "$ownerId" (no request to oneself) and an identityPublicKey refersTo naming recipientKeyIndex (the recipient identity exists, holds that key, and the key is not disabled; the trigger only checked the identity). - encryptedPublicKey / encryptedAccountLabel: encryptedFor with the ecdh-secp256k1-aes256-cbc scheme, so wallets read the recipe from the contract and consensus refuses bytes that are not an IV plus whole AES blocks. senderKeyIndex / recipientKeyIndex gain maximum u32::MAX, which encryptedFor requires; DashPay keeps sizedIntegerTypes off, so both are still stored as i64. - Data trigger bindings 2 (PV14 only) drop the contact request trigger. The three new profile address fields lose their schema descriptions to keep the stored contract small; their formats move to a code comment. DashPay v2 replaces v1 in place at the PV14 upgrade without the contract update checks, so new tests pin that every v1 property keeps its stored encoding, run the registration checks system contracts skip, and read v1 contact documents back unchanged across the upgrade. Co-Authored-By: Claude Opus 5.5 --- book/src/evo-sdk/dashpay-contact-requests.md | 16 ++ .../schema/v2/dashpay.schema.json | 24 +- packages/dashpay-contract/src/v2/mod.rs | 22 +- packages/rs-dpp/src/system_data_contracts.rs | 91 +++++++ .../src/execution/check_tx/v0/mod.rs | 14 +- .../v0/mod.rs | 131 +++++++++- .../data_triggers/bindings/list/v2/mod.rs | 14 +- .../tests/document/dashpay_contact_request.rs | 229 ++++++++++++++++++ .../batch/tests/document/deletion.rs | 2 + .../batch/tests/document/mod.rs | 1 + .../batch/tests/document/replacement.rs | 2 + .../data_contract_reference_validation/mod.rs | 37 +++ .../rs-drive/tests/deterministic_root_hash.rs | 2 +- .../drive_abci_validation_versions/v10.rs | 3 + .../system_data_contract_versions/v3.rs | 5 +- .../rs-platform-version/src/version/v14.rs | 20 +- 16 files changed, 586 insertions(+), 27 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dashpay_contact_request.rs diff --git a/book/src/evo-sdk/dashpay-contact-requests.md b/book/src/evo-sdk/dashpay-contact-requests.md index beaf9e0953e..885f61c031f 100644 --- a/book/src/evo-sdk/dashpay-contact-requests.md +++ b/book/src/evo-sdk/dashpay-contact-requests.md @@ -28,6 +28,22 @@ The current DashPay contract schema requires the system field `$createdAtCoreBlockHeight`. Older external references may use `coreHeightCreatedAt`; do not submit that name to the current contract. +### What consensus checks + +From protocol version 14 the contract declares these checks itself, and a +contact request that fails one is refused (and the fee charged): + +| Rule | Declared as | Error | +| --- | --- | --- | +| `toUserId` is not the sender | `distinctFrom: "$ownerId"` on `toUserId` | `DocumentPropertyNotDistinctError` (10419) | +| The recipient identity exists and has the key `recipientKeyIndex` | `refersTo` of type `identityPublicKey` on `toUserId`, `keyIdProperty: "recipientKeyIndex"` | `ReferencedIdentityKeyNotFoundError` (40123) | +| That key is not disabled | the same `refersTo` | `ReferencedIdentityKeyDisabledError` (40124) | +| `encryptedPublicKey` and `encryptedAccountLabel` are a 16-byte IV plus whole 16-byte blocks | `encryptedFor` with scheme `ecdh-secp256k1-aes256-cbc` | `InvalidEncryptedPropertyShapeError` (10420) | + +Up to protocol version 13 a data trigger made only the first two checks and +reported both as a `DataTriggerConditionError`. Neither version checks the +purpose or contract bounds of either key, or that the bytes decrypt. + `encryptedPublicKey` is exactly 96 bytes: - 16 bytes: AES-CBC initialization vector diff --git a/packages/dashpay-contract/schema/v2/dashpay.schema.json b/packages/dashpay-contract/schema/v2/dashpay.schema.json index 74ea27bdf9c..1524641ca89 100644 --- a/packages/dashpay-contract/schema/v2/dashpay.schema.json +++ b/packages/dashpay-contract/schema/v2/dashpay.schema.json @@ -64,7 +64,6 @@ "byteArray": true, "minItems": 21, "maxItems": 21, - "description": "Core chain address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by the 20-byte HASH160, i.e. RIPEMD160 of SHA256, of the public key or redeem script) for public payments. The type byte is consensus-enforced by a data trigger; clients render the address as Base58Check for the network they are on. Payments to it are publicly linkable to this profile.", "position": 5 }, "platformPaymentAddress": { @@ -72,7 +71,6 @@ "byteArray": true, "minItems": 21, "maxItems": 21, - "description": "Platform address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by the 20-byte HASH160, i.e. RIPEMD160 of SHA256, of the public key or redeem script) for public payments. The type byte is consensus-enforced by a data trigger.", "position": 6 }, "shieldedAddress": { @@ -80,7 +78,6 @@ "byteArray": true, "minItems": 43, "maxItems": 43, - "description": "Raw Orchard receiving address: 11-byte diversifier followed by 32-byte diversified transmission key. Clients validate before payment; wallets should use a dedicated tip account.", "position": 7 } }, @@ -226,23 +223,36 @@ "minItems": 32, "maxItems": 32, "position": 0, - "contentMediaType": "application/x.dash.dpp.identifier" + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyIndex" + }, + "distinctFrom": "$ownerId" }, "encryptedPublicKey": { "type": "array", "byteArray": true, "minItems": 96, "maxItems": 96, + "encryptedFor": { + "recipient": "toUserId", + "recipientKey": "recipientKeyIndex", + "senderKey": "senderKeyIndex", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, "position": 1 }, "senderKeyIndex": { "type": "integer", "minimum": 0, + "maximum": 4294967295, "position": 2 }, "recipientKeyIndex": { "type": "integer", "minimum": 0, + "maximum": 4294967295, "position": 3 }, "accountReference": { @@ -255,6 +265,12 @@ "byteArray": true, "minItems": 48, "maxItems": 80, + "encryptedFor": { + "recipient": "toUserId", + "recipientKey": "recipientKeyIndex", + "senderKey": "senderKeyIndex", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, "position": 5 }, "autoAcceptProof": { diff --git a/packages/dashpay-contract/src/v2/mod.rs b/packages/dashpay-contract/src/v2/mod.rs index a9611b407fb..022f8d82312 100644 --- a/packages/dashpay-contract/src/v2/mod.rs +++ b/packages/dashpay-contract/src/v2/mod.rs @@ -2,9 +2,27 @@ use crate::error::Error; use serde_json::Value; // Document-type name and property constants live in `crate::v1::document_types`; -// v2 does not change any names v1 defined, it only adds the optional +// v2 does not change any names v1 defined. It adds the optional // `corePaymentAddress`, `platformPaymentAddress`, and `shieldedAddress` -// properties to `profile`. +// properties to `profile`, and declares on `contactRequest` what consensus +// checks (`toUserId`: `distinctFrom` and an `identityPublicKey` `refersTo`; +// `encryptedPublicKey` / `encryptedAccountLabel`: `encryptedFor`). +// +// v2 replaces v1 in place at protocol version 14 without the contract update +// checks, and the contract keeps `sizedIntegerTypes` off, so every property v1 +// declares must keep its stored encoding: a key reference on the key id +// property itself (`identityProperty`) would store it as a u32 instead of an +// i64 and misread every stored document. +// +// The schema carries no descriptions for the three profile addresses, to keep +// the stored contract small. Their formats: +// - `corePaymentAddress` / `platformPaymentAddress`: 21 bytes, a type byte +// (0x00 P2PKH, 0x01 P2SH, enforced by the profile data trigger) followed by +// the 20-byte HASH160. Clients render the Core one as Base58Check for their +// network. Payments to either are publicly linkable to the profile. +// - `shieldedAddress`: 43 bytes, a raw Orchard address (11-byte diversifier +// then the 32-byte diversified transmission key). Only the length is checked +// on chain; clients validate it before paying. pub fn load_documents_schemas() -> Result { serde_json::from_str(include_str!("../../schema/v2/dashpay.schema.json")) diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 66b865d0012..e2d76992222 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -296,3 +296,94 @@ mod app_connect_tests { ); } } + +#[cfg(all(test, feature = "dashpay-contract"))] +mod dashpay_tests { + use super::*; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::{ + DistinctFrom, DocumentPropertyReferenceTarget, DocumentPropertyType, EncryptedFor, + EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, + }; + + /// DashPay v2 replaces v1 in place at protocol version 14 (`apply_contract` in + /// `transition_to_version_14`), which never runs the contract update checks, so + /// documents written under v1 are read with v2's types from then on. Every + /// property v1 declares must be stored exactly as v1 stored it. + #[test] + fn should_store_every_dashpay_v1_property_as_v1_did() { + let v1 = load_system_data_contract( + SystemDataContract::Dashpay, + PlatformVersion::get(13).expect("protocol version 13"), + ) + .expect("dashpay v1"); + let v2 = load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay v2"); + + for (type_name, v1_type) in v1.document_types() { + let v2_type = v2 + .document_type_for_name(type_name) + .expect("v2 keeps every v1 document type"); + for (path, v1_property) in v1_type.flattened_properties() { + let v2_property = v2_type + .flattened_properties() + .get(path) + .unwrap_or_else(|| panic!("v2 keeps {type_name}.{path}")); + assert_eq!( + v1_property.property_type.stored_encoding(), + v2_property.property_type.stored_encoding(), + "{type_name}.{path} must be stored as v1 stored it" + ); + } + } + } + + /// From protocol version 14 the contact request's recipient checks are schema + /// declarations instead of a data trigger, and the two ECDH fields name their + /// encryption recipe. + #[test] + fn should_declare_the_contact_request_checks_and_encryption() { + let contract = + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay v2"); + let contact_request = contract + .document_type_for_name("contactRequest") + .expect("contactRequest"); + let to_user_id = contact_request + .flattened_properties() + .get("toUserId") + .expect("toUserId"); + + assert_eq!( + to_user_id.property_type, + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyIndex".to_string(), + key_requirements: IdentityKeyReferenceRequirements::default(), + } + ) + ); + assert_eq!(to_user_id.distinct_from, Some(DistinctFrom::OwnerId)); + + let recipe = EncryptedFor { + recipient: EncryptedForRecipient::Property("toUserId".to_string()), + recipient_key: "recipientKeyIndex".to_string(), + sender_key: "senderKeyIndex".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + }; + let mut encrypted = contact_request + .encrypted_properties() + .into_iter() + .map(|(path, encrypted_for)| (path.as_str(), encrypted_for.clone())) + .collect::>(); + encrypted.sort_by_key(|(path, _)| *path); + assert_eq!( + encrypted, + vec![ + ("encryptedAccountLabel", recipe.clone()), + ("encryptedPublicKey", recipe), + ] + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index da4dbce479c..d1e2701b75d 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -691,7 +691,7 @@ mod tests { // from protocol version 14 the contract's version item is stored in the contract's // other tree (one more tree insert), the config is version 2 (one more byte), and // the larger DashPay v2 schema adds byte-billed contract bytes - 24003037140 + 24002860070 ); let check_result = platform @@ -1045,7 +1045,7 @@ mod tests { // We have one invalid paid for state transition assert_eq!(processing_result.invalid_paid_count(), 1); - assert_eq!(processing_result.aggregated_fees().processing_fee, 346660); + assert_eq!(processing_result.aggregated_fees().processing_fee, 346820); let check_result = platform .check_tx( @@ -1358,7 +1358,7 @@ mod tests { // from protocol version 14 the contract's version item is stored in the contract's // other tree (one more tree insert), the config is version 2 (one more byte), and // the larger DashPay v2 schema adds byte-billed contract bytes - 24006074280 + 24005720140 ); let check_result = platform @@ -1836,7 +1836,7 @@ mod tests { // from protocol version 14 the contract's version item is stored in the contract's // other tree (one more tree insert), the config is version 2 (one more byte), and // the larger DashPay v2 schema adds byte-billed contract bytes - 24003037140 + 24002860070 ); platform @@ -1927,7 +1927,7 @@ mod tests { // other tree (an update reads what key `2` holds, billed, before writing under it), // the config is version 2, and the larger DashPay v2 schema adds byte-billed // contract bytes - 27003119120 + 27002916290 ); let check_result = platform @@ -2518,7 +2518,7 @@ mod tests { // from protocol version 14 the contract's version item is stored in the contract's // other tree (one more tree insert), the config is version 2 (one more byte), and // the larger DashPay v2 schema adds byte-billed contract bytes - 24003037140 + 24002860070 ); platform @@ -2641,7 +2641,7 @@ mod tests { // We have one invalid paid for state transition assert_eq!(processing_result.invalid_paid_count(), 1); - assert_eq!(processing_result.aggregated_fees().processing_fee, 448640); + assert_eq!(processing_result.aggregated_fees().processing_fee, 448800); let check_result = platform .check_tx( diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 5820f88c0c3..3c64b9c5cd0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -1551,7 +1551,7 @@ mod tests { /// payment address fields, and the legacy profile bytes must stay readable /// through normal Drive queries against the refreshed contract. #[tokio::test] - async fn test_protocol_change_v13_to_v14_upgrades_dashpay_and_keeps_v1_profiles_readable() { + async fn test_protocol_change_v13_to_v14_upgrades_dashpay_and_keeps_v1_documents_readable() { use crate::execution::validation::state_transition::tests::setup_identity; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use assert_matches::assert_matches; @@ -1561,9 +1561,10 @@ mod tests { use dpp::data_contract::document_type::random_document::{ CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, }; - use dpp::document::{DocumentV0Getters, DocumentV0Setters}; + use dpp::data_contract::DataContract; + use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::identity::accessors::IdentityGettersV0; - use dpp::platform_value::Bytes32; + use dpp::platform_value::{Bytes32, Value}; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::BatchTransition; @@ -1654,6 +1655,115 @@ mod tests { .unwrap() .expect("expected to commit"); + // A v1 contact request and a v1 contact info. DashPay stores integers as + // i64 (no sized integer types) and v2 replaces v1 in place without the + // contract update checks, so v2 must read both back unchanged + let (recipient, ..) = setup_identity(&mut platform, 495, dash_to_credits!(0.1)); + let mut contact_transitions = vec![]; + for (nonce, document_type_name) in [(3, "contactRequest"), (4, "contactInfo")] { + let document_type = dashpay_v1 + .document_type_for_name(document_type_name) + .expect("expected the document type"); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version_13, + ) + .expect("expected a random v1 document"); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version_13) + .expect("expected to set the document id"); + // Random i64 integers ignore the schema's `minimum`, so the + // integers are set; `accountReference` takes all eight bytes + if document_type_name == "contactRequest" { + // the v1 contact request trigger requires an existing recipient + document.set("toUserId", Value::Identifier(recipient.id().to_buffer())); + document.set("senderKeyIndex", Value::I64(1)); + document.set("recipientKeyIndex", Value::I64(0)); + document.set("accountReference", Value::I64(1 << 40)); + } else { + document.set("rootEncryptionKeyIndex", Value::I64(3)); + document.set("derivationEncryptionKeyIndex", Value::I64(5)); + } + contact_transitions.push( + BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + &key, + nonce, + 0, + None, + &signer, + platform_version_13, + None, + ) + .await + .expect("expect to create documents batch transition") + .serialize_to_bytes() + .expect("expected serialized transition"), + ); + } + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &contact_transitions, + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version_13, + false, + None, + ) + .expect("expected to process state transitions"); + assert_matches!( + processing_result.execution_results().as_slice(), + [ + StateTransitionExecutionResult::SuccessfulExecution { .. }, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let stored_documents = |contract: &DataContract, + document_type_name: &str, + platform_version: &PlatformVersion| + -> Vec { + let query = DriveDocumentQuery::from_sql_expr( + &format!("select * from {document_type_name}"), + contract, + Some(&platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected to query documents") + .documents() + .to_vec() + }; + let contact_documents_v1 = ["contactRequest", "contactInfo"].map(|document_type_name| { + let documents = stored_documents(&dashpay_v1, document_type_name, platform_version_13); + assert_eq!( + documents.len(), + 1, + "expected one stored {document_type_name}" + ); + documents + }); + // Warm the drive contract cache with the v1 contract and confirm the // payment address fields are absent pre-upgrade let transaction = platform.drive.grove.start_transaction(); @@ -1769,6 +1879,21 @@ mod tests { stored_profile_id, "the surviving profile must be the pre-upgrade document" ); + + for (document_type_name, documents_v1) in ["contactRequest", "contactInfo"] + .into_iter() + .zip(contact_documents_v1) + { + assert_eq!( + stored_documents( + &dashpay_v2_fetch_info.contract, + document_type_name, + platform_version_14 + ), + documents_v1, + "v2 must read the v1 {document_type_name} back unchanged" + ); + } } // test_transition_to_version_9 removed: requires prior state from versions 4-8 diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs index 4b7963db392..1acc04e99af 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs @@ -1,5 +1,5 @@ use crate::execution::validation::state_transition::batch::data_triggers::bindings::data_trigger_binding::DataTriggerBindingV0; -use crate::execution::validation::state_transition::batch::data_triggers::triggers::dashpay::{create_contact_request_data_trigger, validate_profile_payment_addresses_data_trigger}; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::dashpay::validate_profile_payment_addresses_data_trigger; use crate::execution::validation::state_transition::batch::data_triggers::triggers::dpns::create_domain_data_trigger; use crate::execution::validation::state_transition::batch::data_triggers::triggers::reject::reject_data_trigger; use crate::execution::validation::state_transition::batch::data_triggers::triggers::withdrawals::delete_withdrawal_data_trigger; @@ -17,7 +17,11 @@ use drive::state_transition_action::batch::batched_transition::document_transiti /// v2 (PROTOCOL_VERSION_14): DashPay `profile` documents gain Create and /// Replace triggers enforcing the DIP-33 payment-address type byte /// (`0x00` P2PKH / `0x01` P2SH) that the schema vocabulary cannot express. -/// Everything else is unchanged from v1. +/// DashPay `contactRequest` creation loses its trigger: the DashPay v2 schema +/// declares both of its checks on `toUserId`, `distinctFrom: "$ownerId"` (no +/// request to oneself) and an `identityPublicKey` `refersTo` (the recipient +/// identity and its `recipientKeyIndex` key exist, and the key is not +/// disabled). Everything else is unchanged from v1. /// /// # Returns /// @@ -49,12 +53,6 @@ pub(super) fn data_trigger_bindings_list_v2() -> Result, + dashpay: Arc, + sender: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + recipient: Identity, + } + + impl Fixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let (sender, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + // Keys 0 and 1 + let (recipient, ..) = setup_identity(&mut platform, 495, dash_to_credits!(0.1)); + + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .expect("expected the dashpay system contract"); + + Fixture { + platform, + dashpay, + sender, + signer, + key, + recipient, + } + } + + /// A contact request from the sender to `to_user_id`'s key + /// `recipient_key_index` whose account label is `label_length` bytes. + fn contact_request( + &self, + to_user_id: Identifier, + recipient_key_index: u32, + label_length: usize, + ) -> (Document, Bytes32) { + let platform_version = PlatformVersion::latest(); + let contact_request = self + .dashpay + .document_type_for_name("contactRequest") + .expect("expected the contactRequest document type"); + + let mut rng = StdRng::seed_from_u64(437); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = contact_request + .random_document_with_identifier_and_entropy( + &mut rng, + self.sender.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(contact_request, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + document.set("toUserId", Value::Identifier(to_user_id.to_buffer())); + document.set("senderKeyIndex", Value::U32(1)); + document.set("recipientKeyIndex", Value::U32(recipient_key_index)); + document.set("accountReference", Value::U32(0)); + document.set("encryptedPublicKey", Value::Bytes(vec![7u8; 96])); + document.set( + "encryptedAccountLabel", + Value::Bytes(vec![8u8; label_length]), + ); + (document, entropy) + } + + async fn create( + &self, + document: Document, + entropy: Bytes32, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + self.dashpay + .document_type_for_name("contactRequest") + .expect("expected the contactRequest document type"), + entropy.0, + &self.key, + 2, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &vec![transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + processing_result.into_execution_results().remove(0) + } + } + + fn paid_error(result: StateTransitionExecutionResult) -> ConsensusError { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + error + } + + #[tokio::test] + async fn should_create_a_contact_request_to_an_existing_key_of_another_identity() { + let fixture = Fixture::new(); + let (document, entropy) = fixture.contact_request(fixture.recipient.id(), 1, 48); + + assert_matches!( + fixture.create(document, entropy).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The data trigger's first check, now `distinctFrom: "$ownerId"`. + #[tokio::test] + async fn should_refuse_a_contact_request_to_oneself() { + let fixture = Fixture::new(); + let (document, entropy) = fixture.contact_request(fixture.sender.id(), 1, 48); + + assert_matches!( + paid_error(fixture.create(document, entropy).await), + ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e)) + if e.document_type_name() == "contactRequest" + && e.property() == "toUserId" + && e.distinct_from() == "$ownerId" + ); + } + + /// The data trigger's second check, now the `identityPublicKey` reference: + /// an identity that does not exist has no key either. + #[tokio::test] + async fn should_refuse_a_contact_request_to_an_identity_that_does_not_exist() { + let fixture = Fixture::new(); + let missing = Identifier::from([0xAB; 32]); + let (document, entropy) = fixture.contact_request(missing, 1, 48); + + assert_matches!( + paid_error(fixture.create(document, entropy).await), + ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError(e)) + if *e.identity_id() == missing && e.key_id() == 1 && e.path() == "toUserId" + ); + } + + /// New from protocol version 14: the trigger only asked whether the + /// recipient identity exists, never whether it has the named key. + #[tokio::test] + async fn should_refuse_a_contact_request_to_a_key_the_recipient_does_not_have() { + let fixture = Fixture::new(); + let (document, entropy) = fixture.contact_request(fixture.recipient.id(), 7, 48); + + assert_matches!( + paid_error(fixture.create(document, entropy).await), + ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError(e)) + if *e.identity_id() == fixture.recipient.id() + && e.key_id() == 7 + && e.path() == "toUserId" + ); + } + + /// New from protocol version 14: an account label of 50 bytes is within the + /// schema's 48 to 80 but is not an IV plus whole AES-CBC blocks. + #[tokio::test] + async fn should_refuse_an_account_label_that_is_not_an_iv_plus_whole_blocks() { + let fixture = Fixture::new(); + let (document, entropy) = fixture.contact_request(fixture.recipient.id(), 1, 50); + + assert_matches!( + paid_error(fixture.create(document, entropy).await), + ConsensusError::BasicError(BasicError::InvalidEncryptedPropertyShapeError(e)) + if e.property() == "encryptedAccountLabel" && e.actual_length() == 50 + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index 1ba8d5de0ed..6e87b4c3d6f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -894,6 +894,8 @@ mod deletion_tests { document.set("recipientKeyIndex", Value::U32(1)); document.set("senderKeyIndex", Value::U32(1)); document.set("accountReference", Value::U32(0)); + // an IV plus whole AES blocks, as the label's `encryptedFor` requires + document.set("encryptedAccountLabel", Value::Bytes(vec![0u8; 48])); let mut altered_document = document.clone(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 23e8c230af1..c0fe7ccf1f9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -1,5 +1,6 @@ mod action_fees; mod creation; +mod dashpay_contact_request; mod deletable_document_reference; mod deletion; mod distinct_from; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 13c73623208..2738cdd2027 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -1141,6 +1141,8 @@ mod replacement_tests { document.set("recipientKeyIndex", Value::U32(1)); document.set("senderKeyIndex", Value::U32(1)); document.set("accountReference", Value::U32(0)); + // an IV plus whole AES blocks, as the label's `encryptedFor` requires + document.set("encryptedAccountLabel", Value::Bytes(vec![0u8; 48])); let mut altered_document = document.clone(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs index f129339b307..3ee23bfeb91 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/mod.rs @@ -48,3 +48,40 @@ pub(in crate::execution::validation::state_transition) fn validate_data_contract })), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::DefaultForPlatformVersion; + + /// A system contract is stored without passing contract create validation, + /// so nothing else runs the registration checks on DashPay's `refersTo` + /// declarations (the `contactRequest.toUserId` key reference from v2). + #[test] + fn should_accept_the_references_the_dashpay_system_contract_declares() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let dashpay = load_system_data_contract(SystemDataContract::Dashpay, platform_version) + .expect("expected the dashpay system contract"); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + + let result = validate_data_contract_references( + &dashpay, + &platform.drive, + &BlockInfo::default(), + &mut execution_context, + None, + platform_version, + ) + .expect("expected the reference validation to run"); + + assert!(result.is_valid(), "{:?}", result.errors); + } +} diff --git a/packages/rs-drive/tests/deterministic_root_hash.rs b/packages/rs-drive/tests/deterministic_root_hash.rs index e7878ecebe3..538ed84572f 100644 --- a/packages/rs-drive/tests/deterministic_root_hash.rs +++ b/packages/rs-drive/tests/deterministic_root_hash.rs @@ -310,7 +310,7 @@ mod tests { // item in the contract's other tree (`[64, id, 2] / 64`), one more tree under // the contract's root subtree, serializes every contract with config version 2, // and ships DashPay contract v2 with the optional `shieldedAddress` profile field. - _ => "c25bc863d30a4185311546d51bacfdc030ee57671dbae774775d0efd4474220c", + _ => "d98bafb506e748c8b1f45ce174f08153e2df671dc505d781d5ef4c9c88353845", }; assert_eq!( diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index e7802f8a616..fab360b65bb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -206,6 +206,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // It keeps v1's PROTOCOL_VERSION_13 change: no reject // bindings for Transfer, Purchase and UpdatePrice on // DPNS `domain` documents (username transfers/sales). + // v2 also drops the DashPay contactRequest create + // binding: the DashPay v2 schema declares its checks. bindings: 2, triggers: DriveAbciValidationDataTriggerVersions { // PROTOCOL_VERSION_12 (v3.1 hard fork): triggers @@ -213,6 +215,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // that bill the cost via add_operation on the // outer execution_context. v0 versions remain // byte-identical to PV11 (don't bill). + // Unbound from PROTOCOL_VERSION_14 (bindings v2). create_contact_request_data_trigger: 1, validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 1, diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index 57f8f319595..9df0423e8d6 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -3,7 +3,10 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // PROTOCOL_VERSION_14: DashPay contract v2 adds the optional public payment // address fields to the `profile` document type (`corePaymentAddress`, // `platformPaymentAddress`) per DIP-33 plus the optional 43-byte Orchard -// `shieldedAddress`, and the withdrawals contract v2 admits +// `shieldedAddress`; its `contactRequest` declares the checks the contact +// request data trigger made (`distinctFrom` and an `identityPublicKey` +// `refersTo` on `toUserId`) and `encryptedFor` on its two ECDH fields. The +// withdrawals contract v2 admits // the terminal FAILED (5) value of the `status` property, written for // withdrawals whose asset unlock Core can never mine. The token history // contract v2 admits the value 2 (OncePerIdentity) of the claim document's diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 8f9acd35690..307f2b49277 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -759,6 +759,24 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// by-id joins refuse a lookup reference as a join property, and /// preallocated indexes are never bound through one. /// +/// 33. **The DashPay contact request declares its checks**: DashPay v2's +/// `contactRequest.toUserId` carries `distinctFrom: "$ownerId"` and an +/// `identityPublicKey` `refersTo` naming `recipientKeyIndex`, and +/// `encryptedPublicKey` and `encryptedAccountLabel` declare `encryptedFor` +/// (`ecdh-secp256k1-aes256-cbc`, recipient `toUserId`, keys +/// `recipientKeyIndex` and `senderKeyIndex`, which gain `maximum` +/// 4294967295). Data trigger bindings 2 (edited in place, only selected +/// from this version) drop the contact request create trigger, whose two +/// checks the declarations now make: a request to oneself is refused with +/// `DocumentPropertyNotDistinctError` (10419) instead of a +/// `DataTriggerConditionError`, and one to a missing identity with +/// `ReferencedIdentityKeyNotFoundError` (40123). New from this version: the +/// recipient must hold the named key (40123) and it must not be disabled +/// (40124), and both encrypted fields must be an IV plus whole AES blocks +/// (10420). No `keyRequirements`: most testnet contact requests use an +/// unbound encryption key for both indexes. DashPay keeps +/// `sizedIntegerTypes` off, so both key indexes stay stored as i64. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -847,7 +865,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V3, // changed: daily_withdrawal_limit v2 — a percentage of the total credits a day ago factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33); withdrawals v2 admits the terminal FAILED status + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33) and declares the contact request checks (item 33); withdrawals v2 admits the terminal FAILED status // The TTL ephemeral-bytes rate (270 credits/byte to processing) rides // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. From 64fb59cb436ffeba8ace3855571b8eeb612a67f0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 10:16:52 +0700 Subject: [PATCH 2/2] test(drive-abci): re-pin the not-mutable replace fees for the fixed-size account label Co-Authored-By: Claude Opus 5.5 --- .../state_transitions/batch/tests/document/replacement.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 2738cdd2027..0aaac734416 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -1254,7 +1254,7 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_not_mutable() { run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version( PlatformVersion::latest().protocol_version, - 460940, // v14: stamped documents (see happy-path baseline note) + 460740, // v14: stamped documents (see happy-path baseline note) ) .await; } @@ -1265,7 +1265,7 @@ mod replacement_tests { /// v13 chain history stays bit-for-bit reproducible. #[tokio::test] async fn test_document_replace_on_document_type_that_is_not_mutable_protocol_version_13() { - run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version(13, 460920) + run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version(13, 460720) .await; }