diff --git a/packages/rs-dpp/src/document/document_factory/mod.rs b/packages/rs-dpp/src/document/document_factory/mod.rs index 2d958c7eb8d..33fb8195ad9 100644 --- a/packages/rs-dpp/src/document/document_factory/mod.rs +++ b/packages/rs-dpp/src/document/document_factory/mod.rs @@ -152,9 +152,531 @@ impl DocumentFactory { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::document::DocumentV0Getters; + use crate::tests::fixtures::get_data_contract_fixture; + use crate::util::entropy_generator::EntropyGenerator; + use platform_value::platform_value; + use platform_version::version::PlatformVersion; + + /// Deterministic entropy generator for tests. + struct TestEntropyGenerator; + + impl EntropyGenerator for TestEntropyGenerator { + fn generate(&self) -> anyhow::Result<[u8; 32]> { + Ok([7u8; 32]) + } + } + + /// Always-failing entropy generator — used to exercise the error + /// surface in `DocumentFactory` when the generator itself fails. + struct FailingEntropyGenerator; + + impl EntropyGenerator for FailingEntropyGenerator { + fn generate(&self) -> anyhow::Result<[u8; 32]> { + Err(anyhow::anyhow!("synthetic entropy failure")) + } + } + + fn setup_factory() -> (DocumentFactory, DataContract) { + let platform_version = PlatformVersion::latest(); + let created = get_data_contract_fixture(None, 0, platform_version.protocol_version); + let data_contract = created.data_contract_owned(); + let factory = DocumentFactory::new_with_entropy_generator( + platform_version.protocol_version, + Box::new(TestEntropyGenerator), + ) + .expect("factory construction should succeed"); + (factory, data_contract) + } + + // ----- Construction ------------------------------------------------------ + + #[test] + fn new_with_bad_protocol_version_returns_error() { + // An invalid protocol version should bubble out of the PlatformVersion lookup. + let result = DocumentFactory::new(u32::MAX); + assert!( + matches!(result, Err(ProtocolError::PlatformVersionError(_))), + "expected PlatformVersionError, got {:?}", + result.err() + ); + } + + #[test] + fn new_with_zero_protocol_version_returns_error() { + // `PlatformVersion::get(0)` also returns an error. + let result = DocumentFactory::new(0); + assert!(result.is_err(), "expected error for version 0"); + } + + #[test] + fn new_with_entropy_generator_bad_version_returns_error() { + let result = + DocumentFactory::new_with_entropy_generator(u32::MAX, Box::new(TestEntropyGenerator)); + assert!(result.is_err()); + } + + #[test] + fn new_with_entropy_generator_valid_version_succeeds() { + let platform_version = PlatformVersion::latest(); + let result = DocumentFactory::new_with_entropy_generator( + platform_version.protocol_version, + Box::new(TestEntropyGenerator), + ); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), DocumentFactory::V0(_))); + } + + #[test] + fn new_variant_is_v0() { + let platform_version = PlatformVersion::latest(); + let factory = DocumentFactory::new(platform_version.protocol_version).unwrap(); + match factory { + DocumentFactory::V0(_) => {} + } + } + + // ----- create_document (error paths) ------------------------------------- + + #[test] + fn create_document_with_invalid_type_returns_error() { + let (factory, data_contract) = setup_factory(); + let owner_id = Identifier::from([0xAAu8; 32]); + + let result = factory.create_document( + &data_contract, + owner_id, + "nonExistentDocType".to_string(), + Value::Null, + ); + + // InvalidDocumentTypeError is a DataContract error wrapped in ProtocolError. + assert!(result.is_err(), "expected error for unknown type"); + } + + #[test] + fn create_document_with_failing_entropy_returns_error() { + // Sanity-check: entropy generator errors surface as ProtocolError. + let platform_version = PlatformVersion::latest(); + let created = get_data_contract_fixture(None, 0, platform_version.protocol_version); + let data_contract = created.data_contract_owned(); + let factory = DocumentFactory::new_with_entropy_generator( + platform_version.protocol_version, + Box::new(FailingEntropyGenerator), + ) + .unwrap(); + + let owner_id = Identifier::from([0x11u8; 32]); + let result = factory.create_document( + &data_contract, + owner_id, + "noTimeDocument".to_string(), + platform_value!({ "name": "x" }), + ); + assert!(result.is_err(), "failing entropy generator should surface"); + } + + #[test] + fn create_document_happy_path_has_zero_time_based_props() { + let (factory, data_contract) = setup_factory(); + let owner_id = Identifier::from([0xBBu8; 32]); + + let doc = factory + .create_document( + &data_contract, + owner_id, + "noTimeDocument".to_string(), + platform_value!({ "name": "widget" }), + ) + .expect("document should be created"); + + // `create_document_without_time_based_properties` is called internally; + // verify the time-based metadata was not populated. + assert_eq!(doc.owner_id(), owner_id); + assert_eq!(doc.created_at(), None); + assert_eq!(doc.updated_at(), None); + } + + // ----- create_extended_document (error paths) ---------------------------- + + #[cfg(feature = "extended-document")] + #[test] + fn create_extended_document_with_invalid_type_returns_error() { + let (factory, data_contract) = setup_factory(); + let owner_id = Identifier::from([0xCCu8; 32]); + + let result = factory.create_extended_document( + &data_contract, + owner_id, + "bogusTypeName".to_string(), + Value::Null, + ); + assert!(result.is_err()); + } + + #[cfg(feature = "extended-document")] + #[test] + fn create_extended_document_happy_path() { + let (factory, data_contract) = setup_factory(); + let owner_id = Identifier::from([0xDDu8; 32]); + + let result = factory.create_extended_document( + &data_contract, + owner_id, + "noTimeDocument".to_string(), + platform_value!({ "name": "z" }), + ); + assert!(result.is_ok(), "extended document creation should succeed"); + let ext = result.unwrap(); + assert_eq!(ext.data_contract_id(), data_contract.id()); + assert_eq!(ext.document_type_name(), "noTimeDocument"); + // Entropy matches our deterministic generator. + assert_eq!(ext.entropy().to_buffer(), [7u8; 32]); + } + + // ----- create_extended_from_document_buffer ------------------------------ + + #[cfg(feature = "extended-document")] + #[test] + fn create_extended_from_document_buffer_roundtrips() { + use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; + let (factory, data_contract) = setup_factory(); + let owner_id = Identifier::from([0x55u8; 32]); + let platform_version = PlatformVersion::latest(); + + let doc = factory + .create_document( + &data_contract, + owner_id, + "noTimeDocument".to_string(), + platform_value!({ "name": "abc" }), + ) + .expect("doc should be created"); + + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let bytes = doc + .serialize(doc_type, &data_contract, platform_version) + .expect("serialize"); + + let ext = factory + .create_extended_from_document_buffer( + bytes.as_slice(), + "noTimeDocument", + &data_contract, + platform_version, + ) + .expect("extended doc should be parsed"); + + assert_eq!(ext.data_contract_id(), data_contract.id()); + assert_eq!(ext.document_type_name(), "noTimeDocument"); + // Buffer-derived extended docs have default (zero) entropy. + assert_eq!(ext.entropy(), &Bytes32::default()); + } + + #[cfg(feature = "extended-document")] + #[test] + fn create_extended_from_document_buffer_invalid_type_fails() { + let (factory, data_contract) = setup_factory(); + let platform_version = PlatformVersion::latest(); + + let result = factory.create_extended_from_document_buffer( + &[0u8; 16], + "thisTypeDoesNotExist", + &data_contract, + platform_version, + ); + assert!(result.is_err(), "unknown doc type should surface error"); + } + + #[cfg(feature = "extended-document")] + #[test] + fn create_extended_from_document_buffer_malformed_bytes_fails() { + let (factory, data_contract) = setup_factory(); + let platform_version = PlatformVersion::latest(); + + // Totally random bytes should not deserialize as a Document. + let result = factory.create_extended_from_document_buffer( + &[0xFFu8; 6], + "noTimeDocument", + &data_contract, + platform_version, + ); + assert!(result.is_err(), "malformed buffer should fail to decode"); + } + + // ----- create_state_transition (error paths) ----------------------------- + + #[cfg(feature = "state-transitions")] + mod state_transition_tests { + use super::*; + use crate::document::errors::DocumentError; + use crate::document::{DocumentV0Setters, INITIAL_REVISION}; + use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionType; + use crate::state_transition::StateTransitionOwned; + + fn build_doc( + factory: &DocumentFactory, + data_contract: &DataContract, + owner: Identifier, + type_name: &str, + ) -> Document { + factory + .create_document( + data_contract, + owner, + type_name.to_string(), + platform_value!({ "name": "x" }), + ) + .expect("doc should build") + } + + #[test] + fn create_state_transition_empty_iter_returns_error() { + let (factory, _) = setup_factory(); + let mut nonce_counter: BTreeMap<(Identifier, Identifier), u64> = BTreeMap::new(); + let empty: Vec<( + DocumentTransitionActionType, + Vec<(Document, DocumentTypeRef, Bytes32, Option)>, + )> = vec![]; + + let result = factory.create_state_transition(empty, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) if matches!(*e, DocumentError::NoDocumentsSuppliedError) + ), + "expected NoDocumentsSuppliedError" + ); + } + + #[test] + fn create_state_transition_outer_iter_has_empty_inner_returns_error() { + // An outer entry with an empty Vec should also yield NoDocumentsSupplied. + let (factory, _) = setup_factory(); + let mut nonce_counter = BTreeMap::new(); + let entries = vec![(DocumentTransitionActionType::Create, vec![])]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) if matches!(*e, DocumentError::NoDocumentsSuppliedError) + ), + "expected NoDocumentsSuppliedError" + ); + } + + #[test] + fn create_state_transition_mismatched_owner_returns_error() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner_a = Identifier::from([0x01u8; 32]); + let owner_b = Identifier::from([0x02u8; 32]); + let doc_a = build_doc(&factory, &data_contract, owner_a, "noTimeDocument"); + let doc_b = build_doc(&factory, &data_contract, owner_b, "noTimeDocument"); + + let mut nonce_counter = BTreeMap::new(); + let entries = vec![( + DocumentTransitionActionType::Create, + vec![ + (doc_a, doc_type, Bytes32::new([1u8; 32]), None), + (doc_b, doc_type, Bytes32::new([2u8; 32]), None), + ], + )]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) + if matches!(*e, DocumentError::MismatchOwnerIdsError { .. }) + ), + "expected MismatchOwnerIdsError" + ); + } + + #[test] + fn create_state_transition_create_wrong_initial_revision_errors() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner = Identifier::from([0x05u8; 32]); + let mut doc = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + doc.set_revision(Some(9999)); + + let mut nonce_counter = BTreeMap::new(); + let entries = vec![( + DocumentTransitionActionType::Create, + vec![(doc, doc_type, Bytes32::default(), None)], + )]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) + if matches!(*e, DocumentError::InvalidInitialRevisionError { .. }) + ), + "expected InvalidInitialRevisionError" + ); + } + + #[test] + fn create_state_transition_create_missing_revision_on_mutable_errors() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner = Identifier::from([0x06u8; 32]); + let mut doc = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + doc.set_revision(None); + + let mut nonce_counter = BTreeMap::new(); + let entries = vec![( + DocumentTransitionActionType::Create, + vec![(doc, doc_type, Bytes32::default(), None)], + )]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) + if matches!(*e, DocumentError::RevisionAbsentError { .. }) + ), + "expected RevisionAbsentError" + ); + } + + #[test] + fn create_state_transition_replace_missing_revision_errors() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner = Identifier::from([0x07u8; 32]); + let mut doc = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + doc.set_revision(None); + + let mut nonce_counter = BTreeMap::new(); + let entries = vec![( + DocumentTransitionActionType::Replace, + vec![(doc, doc_type, Bytes32::default(), None)], + )]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) + if matches!(*e, DocumentError::RevisionAbsentError { .. }) + ), + "expected RevisionAbsentError for replace" + ); + } + + #[test] + fn create_state_transition_delete_missing_revision_errors() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner = Identifier::from([0x08u8; 32]); + let mut doc = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + doc.set_revision(None); + + let mut nonce_counter = BTreeMap::new(); + let entries = vec![( + DocumentTransitionActionType::Delete, + vec![(doc, doc_type, Bytes32::default(), None)], + )]; + let result = factory.create_state_transition(entries, &mut nonce_counter); + assert!( + matches!( + result, + Err(ProtocolError::Document(e)) + if matches!(*e, DocumentError::RevisionAbsentError { .. }) + ), + "expected RevisionAbsentError for delete" + ); + } + + #[test] + fn create_state_transition_create_increments_nonce() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner = Identifier::from([0x20u8; 32]); + let doc = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + + let mut nonce_counter = BTreeMap::new(); + nonce_counter.insert((owner, data_contract.id()), 7); + + let entries = vec![( + DocumentTransitionActionType::Create, + vec![(doc, doc_type, Bytes32::default(), None)], + )]; + let batch = factory + .create_state_transition(entries, &mut nonce_counter) + .expect("should build"); + assert_eq!(batch.owner_id(), owner); + assert_eq!(batch.transitions_len(), 1); + // Pre-seeded nonce 7 → 8. + assert_eq!(*nonce_counter.get(&(owner, data_contract.id())).unwrap(), 8); + } + + #[test] + fn create_state_transition_mix_actions_combines_transitions() { + let (factory, data_contract) = setup_factory(); + let doc_type = data_contract + .document_type_for_name("noTimeDocument") + .unwrap(); + let owner = Identifier::from([0x30u8; 32]); + + // Two create docs (same owner, distinct ids). + let mut c1 = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + c1.set_id(Identifier::from([0xAAu8; 32])); + let mut c2 = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + c2.set_id(Identifier::from([0xBBu8; 32])); + + // One replace doc — must be mutable + have revision. + let mut r1 = build_doc(&factory, &data_contract, owner, "noTimeDocument"); + r1.set_id(Identifier::from([0xCCu8; 32])); + assert_eq!(r1.revision(), Some(INITIAL_REVISION)); + + let mut nonce_counter = BTreeMap::new(); + let entries = vec![ + ( + DocumentTransitionActionType::Create, + vec![ + (c1, doc_type, Bytes32::new([0x01; 32]), None), + (c2, doc_type, Bytes32::new([0x02; 32]), None), + ], + ), + ( + DocumentTransitionActionType::Replace, + vec![(r1, doc_type, Bytes32::default(), None)], + ), + ]; + let batch = factory + .create_state_transition(entries, &mut nonce_counter) + .expect("mixed batch should build"); + assert_eq!(batch.transitions_len(), 3); + // 2 creates + 1 replace = nonce increments 3 times for same (owner, contract). + assert_eq!(*nonce_counter.get(&(owner, data_contract.id())).unwrap(), 3); + } + } +} + // // #[cfg(test)] -// mod test { +// mod old_disabled_test { // use platform_value::btreemap_extensions::BTreeValueMapHelper; // use platform_value::platform_value; // use platform_value::string_encoding::Encoding; diff --git a/packages/rs-dpp/src/document/extended_document/v0/mod.rs b/packages/rs-dpp/src/document/extended_document/v0/mod.rs index e527a7c471e..526823d552d 100644 --- a/packages/rs-dpp/src/document/extended_document/v0/mod.rs +++ b/packages/rs-dpp/src/document/extended_document/v0/mod.rs @@ -1059,4 +1059,262 @@ mod tests { assert_eq!(ext_doc.document_type_name, "profile"); } + + // ================================================================ + // from_untrusted_platform_value: fails when document type missing + // ================================================================ + + #[test] + fn from_untrusted_platform_value_fails_for_missing_type_name() { + let platform_version = PlatformVersion::latest(); + let contract = load_dashpay_contract(platform_version); + + // Value with no $type field — should error out of remove_string + let val = Value::Map(vec![( + Value::Text("$id".to_string()), + Value::Identifier([1u8; 32]), + )]); + + let result = + ExtendedDocumentV0::from_untrusted_platform_value(val, contract, platform_version); + assert!( + result.is_err(), + "missing $type should cause from_untrusted_platform_value to fail" + ); + } + + #[test] + fn from_untrusted_platform_value_fails_for_unknown_document_type() { + let platform_version = PlatformVersion::latest(); + let contract = load_dashpay_contract(platform_version); + + // Value with $type that's not in the contract — document_type_for_name fails + let val = Value::Map(vec![ + ( + Value::Text("$type".to_string()), + Value::Text("doesNotExist".to_string()), + ), + (Value::Text("$id".to_string()), Value::Identifier([1u8; 32])), + ( + Value::Text("$ownerId".to_string()), + Value::Identifier([2u8; 32]), + ), + ]); + + let result = + ExtendedDocumentV0::from_untrusted_platform_value(val, contract, platform_version); + assert!( + result.is_err(), + "unknown document type name should cause error" + ); + } + + // ================================================================ + // from_trusted_platform_value: fails when document type missing + // ================================================================ + + #[test] + fn from_trusted_platform_value_fails_for_missing_type_name() { + let platform_version = PlatformVersion::latest(); + let contract = load_dashpay_contract(platform_version); + + let val = Value::Map(vec![( + Value::Text("$id".to_string()), + Value::Identifier([1u8; 32]), + )]); + + let result = + ExtendedDocumentV0::from_trusted_platform_value(val, contract, platform_version); + assert!(result.is_err()); + } + + // ================================================================ + // set_untrusted: exercises identifier-path, binary-path, and other branches + // ================================================================ + + #[test] + fn set_untrusted_non_identifier_non_binary_path_sets_raw_value() { + let platform_version = PlatformVersion::latest(); + let (mut ext_doc, _) = make_extended_document(platform_version); + + // "displayName" is neither an identifier path nor a binary path + ext_doc + .set_untrusted("displayName", Value::Text("Alice".to_string())) + .expect("set_untrusted should succeed for plain string field"); + + assert_eq!( + ext_doc.get_optional_value("displayName"), + Some(&Value::Text("Alice".to_string())) + ); + } + + // ================================================================ + // hash: differs when documents differ + // ================================================================ + + #[test] + fn hash_differs_for_different_documents() { + let platform_version = PlatformVersion::latest(); + let contract = load_dashpay_contract(platform_version); + let document_type = contract + .document_type_for_name("profile") + .expect("expected profile document type"); + + let doc_a = document_type + .random_document(Some(1), platform_version) + .expect("random doc a"); + let doc_b = document_type + .random_document(Some(2), platform_version) + .expect("random doc b"); + + let ext_a = ExtendedDocumentV0::from_document_with_additional_info( + doc_a, + contract.clone(), + "profile".to_string(), + None, + ); + let ext_b = ExtendedDocumentV0::from_document_with_additional_info( + doc_b, + contract, + "profile".to_string(), + None, + ); + + let hash_a = ext_a.hash(platform_version).expect("hash a"); + let hash_b = ext_b.hash(platform_version).expect("hash b"); + assert_ne!(hash_a, hash_b, "hashes of different documents must differ"); + } + + // ================================================================ + // serialize_specific_version_to_bytes + // ================================================================ + + #[test] + fn serialize_specific_version_to_bytes_v0_succeeds() { + let platform_version = PlatformVersion::latest(); + let (ext_doc, _) = make_extended_document(platform_version); + + let bytes = ext_doc + .serialize_specific_version_to_bytes(0, platform_version) + .expect("v0 serialization should succeed"); + assert!(!bytes.is_empty()); + // First byte should be the varint for 0 + assert_eq!(bytes[0], 0); + } + + #[test] + fn serialize_specific_version_to_bytes_rejects_unknown_version() { + let platform_version = PlatformVersion::latest(); + let (ext_doc, _) = make_extended_document(platform_version); + + let result = ext_doc.serialize_specific_version_to_bytes(99, platform_version); + assert!( + matches!( + result, + Err(crate::ProtocolError::UnknownVersionMismatch { received: 99, .. }) + ), + "unknown feature version should yield UnknownVersionMismatch, got {:?}", + result + ); + } + + // ================================================================ + // from_bytes deserialization error paths + // ================================================================ + + #[test] + fn from_bytes_empty_buffer_fails() { + let platform_version = PlatformVersion::latest(); + let result = ExtendedDocumentV0::from_bytes(&[], platform_version); + assert!( + result.is_err(), + "empty buffer should fail ExtendedDocumentV0::from_bytes" + ); + } + + #[test] + fn from_bytes_unknown_version_fails() { + let platform_version = PlatformVersion::latest(); + use integer_encoding::VarInt; + // Varint 42 followed by junk + let mut buf = 42u64.encode_var_vec(); + buf.extend_from_slice(&[0u8; 32]); + + let result = ExtendedDocumentV0::from_bytes(&buf, platform_version); + assert!( + matches!( + result, + Err(crate::ProtocolError::UnknownVersionMismatch { received: 42, .. }) + ), + "unknown serialized version should yield UnknownVersionMismatch, got {:?}", + result + ); + } + + #[test] + fn from_bytes_v0_truncated_contract_fails() { + let platform_version = PlatformVersion::latest(); + use integer_encoding::VarInt; + // Valid version prefix but no contract data at all — the contract + // deserialization should fail. + let buf = 0u64.encode_var_vec(); + + let result = ExtendedDocumentV0::from_bytes(&buf, platform_version); + assert!( + result.is_err(), + "truncated buffer (no contract) must fail deserialization" + ); + } + + // ================================================================ + // to_json / to_json_with_identifiers_using_bytes + // ================================================================ + + #[test] + fn to_json_includes_type_and_data_contract() { + let platform_version = PlatformVersion::latest(); + let (ext_doc, _) = make_extended_document(platform_version); + + let json = ext_doc + .to_json(platform_version) + .expect("to_json should succeed"); + let obj = json.as_object().expect("json object"); + assert!( + obj.contains_key(property_names::DOCUMENT_TYPE_NAME), + "must contain $type" + ); + assert!( + obj.contains_key(property_names::DATA_CONTRACT), + "must contain $dataContract" + ); + } + + #[test] + fn to_json_with_identifiers_using_bytes_includes_type_and_contract() { + let platform_version = PlatformVersion::latest(); + let (ext_doc, _) = make_extended_document(platform_version); + + let json = ext_doc + .to_json_with_identifiers_using_bytes(platform_version) + .expect("to_json_with_identifiers_using_bytes should succeed"); + let obj = json.as_object().expect("json object"); + assert!(obj.contains_key(property_names::DOCUMENT_TYPE_NAME)); + assert!(obj.contains_key(property_names::DATA_CONTRACT)); + } + + // ================================================================ + // to_map_value: token_payment_info is serialized when Some + // ================================================================ + + #[test] + fn to_map_value_omits_token_payment_info_when_none() { + let platform_version = PlatformVersion::latest(); + let (ext_doc, _) = make_extended_document(platform_version); + assert!(ext_doc.token_payment_info.is_none()); + let map = ext_doc.to_map_value().expect("to_map_value should succeed"); + assert!( + !map.contains_key(property_names::TOKEN_PAYMENT_INFO), + "token_payment_info should be absent when None" + ); + } } diff --git a/packages/rs-dpp/src/document/v0/mod.rs b/packages/rs-dpp/src/document/v0/mod.rs index 3f0264b09b1..f34d0e7d6a7 100644 --- a/packages/rs-dpp/src/document/v0/mod.rs +++ b/packages/rs-dpp/src/document/v0/mod.rs @@ -210,3 +210,192 @@ impl fmt::Display for DocumentV0 { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{DocumentV0Getters, DocumentV0Setters}; + use platform_value::Identifier; + + fn minimal_doc() -> DocumentV0 { + DocumentV0 { + id: Identifier::new([1u8; 32]), + owner_id: Identifier::new([2u8; 32]), + properties: BTreeMap::new(), + revision: None, + 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, + } + } + + // ================================================================ + // Display impl: exercise each optional-field branch + // ================================================================ + + #[test] + fn display_minimal_document_has_no_properties_marker() { + let doc = minimal_doc(); + let s = format!("{}", doc); + assert!(s.contains("id:"), "should contain id"); + assert!(s.contains("owner_id:"), "should contain owner_id"); + assert!( + s.contains("no properties"), + "empty properties should render as 'no properties', got: {s}" + ); + } + + #[test] + fn display_with_properties_formats_key_value_pairs() { + let mut doc = minimal_doc(); + doc.properties + .insert("name".to_string(), Value::Text("Bob".to_string())); + let s = format!("{}", doc); + assert!(!s.contains("no properties")); + assert!(s.contains("name:"), "should contain property key"); + } + + #[test] + fn display_formats_all_optional_timestamp_fields() { + let mut doc = minimal_doc(); + // Set every optional field to exercise each branch of Display + doc.created_at = Some(1_700_000_000_000); + doc.updated_at = Some(1_700_000_100_000); + doc.transferred_at = Some(1_700_000_200_000); + doc.created_at_block_height = Some(10); + doc.updated_at_block_height = Some(20); + doc.transferred_at_block_height = Some(30); + doc.created_at_core_block_height = Some(1); + doc.updated_at_core_block_height = Some(2); + doc.transferred_at_core_block_height = Some(3); + doc.creator_id = Some(Identifier::new([9u8; 32])); + + let s = format!("{}", doc); + // Each branch should emit its labeled prefix + assert!(s.contains("created_at:"), "missing created_at: {s}"); + assert!(s.contains("updated_at:"), "missing updated_at: {s}"); + assert!(s.contains("transferred_at:"), "missing transferred_at: {s}"); + assert!( + s.contains("created_at_block_height:10"), + "missing created_at_block_height: {s}" + ); + assert!( + s.contains("updated_at_block_height:20"), + "missing updated_at_block_height: {s}" + ); + assert!( + s.contains("transferred_at_block_height:30"), + "missing transferred_at_block_height: {s}" + ); + assert!( + s.contains("created_at_core_block_height:1"), + "missing created_at_core_block_height: {s}" + ); + assert!( + s.contains("updated_at_core_block_height:2"), + "missing updated_at_core_block_height: {s}" + ); + assert!( + s.contains("transferred_at_core_block_height:3"), + "missing transferred_at_core_block_height: {s}" + ); + assert!(s.contains("creator_id:"), "missing creator_id: {s}"); + } + + #[test] + fn display_invalid_timestamp_uses_default_formatter() { + // Timestamps that overflow DateTime should use `.unwrap_or_default()`. + // This ensures the "unwrap_or_default()" branch of Display is hit. + let mut doc = minimal_doc(); + // u64::MAX casts to -1i64, which IS inside chrono's range (1 ms before + // epoch). Use i64::MAX instead — it exceeds chrono's supported ms + // range (~262,000 years) so `from_timestamp_millis` returns None and + // the `.unwrap_or_default()` branch is actually exercised. + doc.created_at = Some(i64::MAX as u64); + let s = format!("{}", doc); + // Must not panic and must contain the created_at prefix + assert!(s.contains("created_at:")); + } + + // ================================================================ + // bump_revision: saturating behavior and None pass-through + // ================================================================ + + #[test] + fn bump_revision_increments_when_some() { + let mut doc = minimal_doc(); + doc.set_revision(Some(5)); + doc.bump_revision(); + assert_eq!(doc.revision(), Some(6)); + } + + #[test] + fn bump_revision_is_noop_when_none() { + let mut doc = minimal_doc(); + assert_eq!(doc.revision(), None); + doc.bump_revision(); + // None -> None; no panic, no change. + assert_eq!(doc.revision(), None); + } + + #[test] + fn bump_revision_saturates_at_max() { + let mut doc = minimal_doc(); + doc.set_revision(Some(Revision::MAX)); + doc.bump_revision(); + // saturating_add should cap at MAX, not wrap + assert_eq!(doc.revision(), Some(Revision::MAX)); + } + + // ================================================================ + // Default impl + // ================================================================ + + #[test] + fn default_document_has_zero_identifiers_and_none_fields() { + let doc = DocumentV0::default(); + assert_eq!(doc.id, Identifier::new([0u8; 32])); + assert_eq!(doc.owner_id, Identifier::new([0u8; 32])); + assert!(doc.properties.is_empty()); + assert_eq!(doc.revision, None); + assert_eq!(doc.created_at, None); + assert_eq!(doc.updated_at, None); + assert_eq!(doc.transferred_at, None); + assert_eq!(doc.creator_id, None); + } + + // ================================================================ + // PartialEq semantics + // ================================================================ + + #[test] + fn documents_with_different_creator_id_are_not_equal() { + let a = minimal_doc(); + let mut b = minimal_doc(); + b.creator_id = Some(Identifier::new([7u8; 32])); + assert_ne!(a, b); + } + + #[test] + fn documents_with_equal_fields_are_equal() { + let a = minimal_doc(); + let b = minimal_doc(); + assert_eq!(a, b); + } + + #[test] + fn clone_produces_equal_document() { + let mut doc = minimal_doc(); + doc.properties.insert("k".to_string(), Value::U64(42)); + doc.revision = Some(3); + let cloned = doc.clone(); + assert_eq!(doc, cloned); + } +} diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index dd43be3bbe7..fd85f71c9a4 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -306,3 +306,153 @@ pub(crate) mod test_helpers { SpendableNote { note, merkle_path } } } + +#[cfg(test)] +mod mod_tests { + use super::test_helpers::{test_orchard_address, test_spendable_note, TestProver}; + use super::*; + use grovedb_commitment_tree::{FullViewingKey, SpendAuthorizingKey, SpendingKey}; + + // ------------------------------------------------------------------ + // `build_output_only_bundle` — exercise the happy path covering the + // internal builder configuration and `prove_and_sign_bundle` pipeline + // on the empty-signing-keys branch. + // ------------------------------------------------------------------ + + #[test] + fn output_only_bundle_flags_and_value_balance() { + let recipient = test_orchard_address(); + let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], &TestProver) + .expect("bundle should build"); + + // Spends are disabled for Shield / ShieldFromAssetLock bundles. + assert!(!bundle.flags().spends_enabled()); + assert!(bundle.flags().outputs_enabled()); + // Orchard value_balance is negative when net value enters the pool. + assert_eq!(*bundle.value_balance(), -10_000i64); + assert!( + !bundle.actions().is_empty(), + "at least one padding action expected" + ); + } + + // ------------------------------------------------------------------ + // `serialize_authorized_bundle` — verify the mapping from a fully + // authorized bundle into the raw state-transition fields. + // ------------------------------------------------------------------ + + #[test] + fn serialize_authorized_bundle_preserves_fields() { + let recipient = test_orchard_address(); + let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], &TestProver) + .expect("bundle should build"); + let sb = serialize_authorized_bundle(&bundle); + + assert_eq!(sb.value_balance, *bundle.value_balance()); + assert_eq!(sb.flags, bundle.flags().to_byte()); + assert_eq!(sb.anchor, bundle.anchor().to_bytes()); + assert!(!sb.proof.is_empty(), "Halo 2 proof must not be empty"); + assert_eq!(sb.binding_signature.len(), 64); + assert_eq!(sb.actions.len(), bundle.actions().len()); + for action in &sb.actions { + // Each encrypted_note packs epk (32) + enc_ciphertext (580... wait — 84+512? verify via cap 216) + // The explicit layout from serialize_authorized_bundle: epk_bytes (32) + + // enc_ciphertext + out_ciphertext = 580 + 80? The code pre-allocates 216. + // Don't hardcode length — just verify non-empty and signature sizes. + assert!(!action.encrypted_note.is_empty()); + assert_eq!(action.nullifier.len(), 32); + assert_eq!(action.cmx.len(), 32); + assert_eq!(action.cv_net.len(), 32); + assert_eq!(action.rk.len(), 32); + assert_eq!(action.spend_auth_sig.len(), 64); + } + } + + // ------------------------------------------------------------------ + // `From<&OrchardAddress> for PaymentAddress` delegates to `inner()`. + // ------------------------------------------------------------------ + + #[test] + fn from_orchard_address_to_payment_address_preserves_bytes() { + let addr = test_orchard_address(); + let pa: PaymentAddress = (&addr).into(); + assert_eq!( + pa.to_raw_address_bytes(), + addr.inner().to_raw_address_bytes() + ); + } + + // ------------------------------------------------------------------ + // `build_spend_bundle` — exercise the `add_spend` error path. The + // helper notes don't reconcile to `Anchor::empty_tree()` (the + // commitment and the all-zeros Merkle path don't match), so adding + // the spend surfaces an AnchorMismatch error wrapped in + // `ProtocolError::ShieldedBuildError`. + // ------------------------------------------------------------------ + + #[test] + fn build_spend_bundle_add_spend_anchor_mismatch_surfaces_error() { + let recipient = test_orchard_address(); + let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let spends = vec![test_spendable_note(50_000)]; + + let result = build_spend_bundle( + spends, + &recipient, + 40_000, + [1u8; 36], + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + &[], + ); + let err = result.expect_err("anchor mismatch should bubble up"); + match err { + ProtocolError::ShieldedBuildError(msg) => { + assert!( + msg.contains("failed to add spend") + || msg.contains("AnchorMismatch") + || msg.contains("anchor"), + "unexpected error message: {}", + msg + ); + } + other => panic!("expected ShieldedBuildError, got {:?}", other), + } + } + + #[test] + fn build_spend_bundle_empty_spends_still_returns_some_output_bundle_or_error() { + // Exercise the loop-never-executed branch: no spends at all. The + // Orchard builder configuration `BundleType::DEFAULT` requires at + // least one spend by default — expect an error wrapped as + // `ShieldedBuildError`. + let recipient = test_orchard_address(); + let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_spend_bundle( + vec![], + &recipient, + 0, + [0u8; 36], + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + &[], + ); + // Whatever the outcome, it should be deterministic: either Ok (with + // padding) or a clean ShieldedBuildError — never a panic. + match result { + Ok(_) => {} + Err(ProtocolError::ShieldedBuildError(_)) => {} + Err(e) => panic!("unexpected error kind: {:?}", e), + } + } +} diff --git a/packages/rs-dpp/src/shielded/builder/shield.rs b/packages/rs-dpp/src/shielded/builder/shield.rs index 91eac886619..425b38a59e7 100644 --- a/packages/rs-dpp/src/shielded/builder/shield.rs +++ b/packages/rs-dpp/src/shielded/builder/shield.rs @@ -150,4 +150,104 @@ mod tests { other => panic!("expected Shield variant, got {:?}", other), } } + + // ------------------------------------------------------------ + // Extra coverage: error/edge paths not exercised above. + // ------------------------------------------------------------ + + #[test] + fn test_build_shield_multiple_inputs_all_plumbed() { + // Multiple input addresses should each produce their own witness + // signature and flow through the downstream Shield transition. + let recipient = test_orchard_address(); + let platform_version = PlatformVersion::latest(); + + let mut inputs = BTreeMap::new(); + inputs.insert(PlatformAddress::P2pkh([1u8; 20]), (0u32, 100_000u64)); + inputs.insert(PlatformAddress::P2pkh([2u8; 20]), (0u32, 200_000u64)); + inputs.insert(PlatformAddress::P2pkh([3u8; 20]), (0u32, 300_000u64)); + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let result = build_shield_transition( + &recipient, + 50_000, + inputs, + fee_strategy, + &DummySigner, + 0, + &TestProver, + [0u8; 36], + platform_version, + ); + assert!( + result.is_ok(), + "multi-input shield should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_build_shield_user_fee_increase_non_zero_succeeds() { + // The user_fee_increase param just flows through as metadata. + // A non-zero value should not fail the bundle build. + let recipient = test_orchard_address(); + let platform_version = PlatformVersion::latest(); + let input_address = PlatformAddress::P2pkh([5u8; 20]); + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (0u32, 500_000u64)); + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let result = build_shield_transition( + &recipient, + 100_000, + inputs, + fee_strategy, + &DummySigner, + 42, // non-zero fee increase + &TestProver, + [9u8; 36], + platform_version, + ); + assert!( + result.is_ok(), + "non-zero user_fee_increase should succeed: {:?}", + result.err() + ); + } + + #[test] + fn test_build_shield_memo_is_fully_plumbed() { + // Any 36-byte memo should be accepted — this test is a guard + // against accidental panics/regressions in memo handling. + let recipient = test_orchard_address(); + let platform_version = PlatformVersion::latest(); + let input_address = PlatformAddress::P2pkh([9u8; 20]); + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (5u32, 200_000u64)); + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + let mut memo = [0u8; 36]; + for (i, b) in memo.iter_mut().enumerate() { + *b = i as u8; + } + + let result = build_shield_transition( + &recipient, + 80_000, + inputs, + fee_strategy, + &DummySigner, + 0, + &TestProver, + memo, + platform_version, + ); + assert!( + result.is_ok(), + "varied memo should succeed: {:?}", + result.err() + ); + } } diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index 09ce869b283..bfa6ebf890d 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -90,4 +90,48 @@ mod tests { .expect("value_balance should be safely negatable"); assert_eq!(abs_balance, amount); } + + // ------------------------------------------------------------- + // Arithmetic edge cases on the value_balance conversion branch + // (the `checked_neg().and_then(u64::try_from)` chain). + // ------------------------------------------------------------- + + #[test] + fn test_value_balance_positive_would_fail_conversion() { + // This is a regression-guard: if a *positive* value_balance ever + // reached the conversion path, `checked_neg` on i64::MIN would + // overflow and the `.try_from::` on a negative value would + // fail. We simulate by constructing a hypothetical value_balance + // scenario rather than calling the high-level builder (which + // requires a real AssetLockProof). + let positive: i64 = 123; + let converted = positive.checked_neg().and_then(|v| u64::try_from(v).ok()); + assert!(converted.is_none(), "negative result cannot be u64"); + + let zero: i64 = 0; + let converted_zero = zero.checked_neg().and_then(|v| u64::try_from(v).ok()); + assert_eq!(converted_zero, Some(0)); + + let negative: i64 = -42; + let converted_neg = negative.checked_neg().and_then(|v| u64::try_from(v).ok()); + assert_eq!(converted_neg, Some(42)); + } + + #[test] + fn test_output_only_various_amounts_negative_balance() { + // Try several amounts to ensure the helper consistently produces a + // negative value_balance equal in magnitude to the requested amount. + for amount in [1u64, 100, 1_000_000, u32::MAX as u64] { + let recipient = test_orchard_address(); + let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], &TestProver) + .expect("bundle should build"); + let sb = serialize_authorized_bundle(&bundle); + assert_eq!( + sb.value_balance, + -(amount as i64), + "value_balance mismatch for amount {}", + amount + ); + } + } } diff --git a/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs b/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs index 40ecd870635..3abda078c64 100644 --- a/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs +++ b/packages/rs-dpp/src/shielded/builder/shielded_transfer.rs @@ -215,4 +215,173 @@ mod tests { err ); } + + // -------------------------------------------------------------- + // Extra coverage — error/overflow branches + // -------------------------------------------------------------- + + #[test] + fn test_shielded_transfer_fee_above_upper_bound() { + // Fee > 1000x the minimum fee should be rejected. + let platform_version = PlatformVersion::latest(); + let recipient = test_orchard_address(); + let change_address = test_orchard_address(); + + let note = test_spendable_note(u64::MAX); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]) + .expect("valid spending key bytes"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + // num_actions is max(spends.len(), 2) = 2. + let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version); + let excessive_fee = min_fee.saturating_mul(1000) + 1; + + let result = build_shielded_transfer_transition( + spends, + &recipient, + 10, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + Some(excessive_fee), + platform_version, + ); + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds 1000x the minimum fee"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_shielded_transfer_fee_plus_amount_overflow_errors() { + // transfer_amount + fee overflows u64 → dedicated error branch. + let platform_version = PlatformVersion::latest(); + let recipient = test_orchard_address(); + let change_address = test_orchard_address(); + + let note = test_spendable_note(u64::MAX); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]) + .expect("valid spending key bytes"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + // Compute min fee, then craft a fee that lies in [min_fee, 1000*min_fee] + // so we bypass the boundary checks, then pick transfer_amount = u64::MAX + // so amount + fee overflows. + let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version); + + let result = build_shielded_transfer_transition( + spends, + &recipient, + u64::MAX, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + Some(min_fee), // within [min, 1000*min] + platform_version, + ); + + assert!(result.is_err(), "overflow case should error"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("fee + transfer_amount overflows u64"), + "expected checked_add overflow branch, got: {}", + err + ); + } + + #[test] + fn test_shielded_transfer_zero_spends_total_is_zero_errors() { + // Empty spends → total_spent = 0. Any non-zero transfer will fail + // with "exceeds total spendable value". This exercises the + // `num_actions = max(0, 2) = 2` branch. + let platform_version = PlatformVersion::latest(); + let recipient = test_orchard_address(); + let change_address = test_orchard_address(); + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_shielded_transfer_transition( + vec![], + &recipient, + 1, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds total spendable value"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_shielded_transfer_fee_default_is_min_fee() { + // When fee is None, the default min fee is computed — verify that a + // note *exactly* equal to `transfer_amount + min_fee` on the default + // branch does not spuriously fail the "exceeds total" check (it + // fails later in add_spend due to anchor mismatch). + let platform_version = PlatformVersion::latest(); + let recipient = test_orchard_address(); + let change_address = test_orchard_address(); + + let min_fee = crate::shielded::compute_minimum_shielded_fee(2, platform_version); + let transfer_amount = 10u64; + let note = test_spendable_note(transfer_amount + min_fee); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_shielded_transfer_transition( + spends, + &recipient, + transfer_amount, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + + // With a valid fee/amount relationship, the builder proceeds past + // the amount checks and hits the add_spend AnchorMismatch. + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("failed to add spend") + || err_msg.contains("anchor") + || err_msg.contains("AnchorMismatch"), + "expected downstream add_spend error, got: {}", + err_msg + ); + } } diff --git a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs index 77d0edfbadc..f5885a5c1d1 100644 --- a/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs +++ b/packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs @@ -252,4 +252,171 @@ mod tests { err ); } + + // -------------------------------------------------------------- + // Extra coverage — upper-bound / overflow / default branches + // -------------------------------------------------------------- + + #[test] + fn test_shielded_withdrawal_amount_exceeds_i64_max_errors() { + // `withdrawal_amount > i64::MAX as u64` is the first check and has + // its own error branch. + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + + let note = test_spendable_note(u64::MAX); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_shielded_withdrawal_transition( + spends, + (i64::MAX as u64) + 1, // exceeds the i64 limit + CoreScript::new_p2pkh([1u8; 20]), + 1, + Pooling::Never, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds maximum allowed value"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_shielded_withdrawal_fee_at_exact_upper_bound_accepted() { + // Boundary test: fee == 1000x the minimum is the *accepted* upper + // limit (strictly > is rejected). The builder should proceed past + // the fee validation and only fail later at add_spend. + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + + let note = test_spendable_note(u64::MAX); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version); + let fee_at_boundary = min_fee.saturating_mul(1000); + + let result = build_shielded_withdrawal_transition( + spends, + 100, + CoreScript::new_p2pkh([1u8; 20]), + 1, + Pooling::Never, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + Some(fee_at_boundary), + platform_version, + ); + // Boundary value passes the validation, so it must NOT fail with + // "exceeds 1000x". A successful build is also acceptable; only a + // later-stage failure (anchor/add_spend) should surface — and never + // as the upper-bound error. + if let Err(err) = result { + let err = err.to_string(); + assert!( + !err.contains("exceeds 1000x"), + "boundary value should not trigger upper-bound error: {}", + err + ); + } + } + + #[test] + fn test_shielded_withdrawal_fee_at_exact_min_accepted() { + // Boundary test: fee == min_fee should be accepted (strictly `<` + // is rejected). + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + + let note = test_spendable_note(1_000_000); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version); + + let result = build_shielded_withdrawal_transition( + spends, + 100, + CoreScript::new_p2pkh([1u8; 20]), + 1, + Pooling::Never, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + Some(min_fee), + platform_version, + ); + // Fee == min_fee is accepted by validation; a successful build is + // fine. Only a later-stage failure should surface — and never with + // the "below minimum required fee" message. + if let Err(err) = result { + let err = err.to_string(); + assert!( + !err.contains("below minimum required fee"), + "fee at min must not trip the lower bound: {}", + err + ); + } + } + + #[test] + fn test_shielded_withdrawal_zero_spends_errors() { + // Empty spends vec → total_spent = 0 and num_actions = 1 (max). + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_shielded_withdrawal_transition( + vec![], + 1, + CoreScript::new_p2pkh([1u8; 20]), + 1, + Pooling::Never, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds total spendable value"), + "unexpected error: {}", + err + ); + } } diff --git a/packages/rs-dpp/src/shielded/builder/unshield.rs b/packages/rs-dpp/src/shielded/builder/unshield.rs index 05e12957f7b..cd44b74e336 100644 --- a/packages/rs-dpp/src/shielded/builder/unshield.rs +++ b/packages/rs-dpp/src/shielded/builder/unshield.rs @@ -239,4 +239,195 @@ mod tests { err ); } + + // -------------------------------------------------------------- + // Extra coverage — bounds / overflow / empty-spends branches + // -------------------------------------------------------------- + + #[test] + fn test_unshield_amount_exceeds_i64_max_errors() { + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + let output_address = PlatformAddress::P2pkh([1u8; 20]); + + let note = test_spendable_note(u64::MAX); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_unshield_transition( + spends, + output_address, + (i64::MAX as u64) + 1, // overflow the i64 cap + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds maximum allowed value"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_unshield_fee_at_exact_upper_bound_passes_validation() { + // Boundary: fee == 1000x min is accepted (strictly > fails). + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + let output_address = PlatformAddress::P2pkh([1u8; 20]); + + let note = test_spendable_note(u64::MAX); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version); + let boundary = min_fee.saturating_mul(1000); + + let result = build_unshield_transition( + spends, + output_address, + 100, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + Some(boundary), + platform_version, + ); + // Boundary value passes validation; a successful build is fine. If a + // later-stage failure (anchor/add_spend) surfaces, it must NOT be the + // upper-bound error. + if let Err(err) = result { + let err = err.to_string(); + assert!( + !err.contains("exceeds 1000x"), + "boundary fee should be accepted: {}", + err + ); + } + } + + #[test] + fn test_unshield_amount_exceeds_spendable_with_default_fee() { + // unshield_amount + default_min_fee > total_spent should surface + // the "exceeds total spendable value" branch. + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + let output_address = PlatformAddress::P2pkh([1u8; 20]); + + let note = test_spendable_note(5_000); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_unshield_transition( + spends, + output_address, + 6_000, // more than the note's 5_000 + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds total spendable value"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_unshield_zero_spends_errors() { + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + let output_address = PlatformAddress::P2pkh([1u8; 20]); + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_unshield_transition( + vec![], + output_address, + 1, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("exceeds total spendable value"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_unshield_fee_default_sufficient_value_reaches_add_spend() { + // When fee=None and total_spent exactly covers (amount + min_fee), + // we bypass all amount checks and hit the downstream add_spend + // AnchorMismatch. This exercises the default-fee branch. + let platform_version = PlatformVersion::latest(); + let change_address = test_orchard_address(); + let output_address = PlatformAddress::P2pkh([1u8; 20]); + + let min_fee = crate::shielded::compute_minimum_shielded_fee(1, platform_version); + let unshield_amount = 42u64; + let note = test_spendable_note(unshield_amount + min_fee); + let spends = vec![note]; + + let sk = grovedb_commitment_tree::SpendingKey::from_bytes([42u8; 32]).expect("valid sk"); + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + + let result = build_unshield_transition( + spends, + output_address, + unshield_amount, + &change_address, + &fvk, + &ask, + Anchor::empty_tree(), + &TestProver, + [0u8; 36], + None, + platform_version, + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("failed to add spend") + || err_msg.contains("anchor") + || err_msg.contains("AnchorMismatch"), + "expected downstream add_spend error, got: {}", + err_msg + ); + } } diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index f8e0b00a8fd..e6ea63fd932 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1842,4 +1842,862 @@ mod tests { let cloned = st.clone(); assert_eq!(st, cloned); } + + // ----------------------------------------------------------------------- + // Additional coverage: enum arms that weren't previously exercised. + // + // The tests below intentionally target variants the earlier tests did not + // touch (DataContractCreate, DataContractUpdate, Batch, IdentityCreate, + // IdentityTopUp, IdentityUpdate, shielded / address variants) to cover + // the remaining match-arm branches in accessor / mutator / classification + // methods. + // ----------------------------------------------------------------------- + + use crate::data_contract::serialized_version::DataContractInSerializationFormat; + use crate::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use crate::state_transition::batch_transition::document_delete_transition::{ + DocumentDeleteTransition, DocumentDeleteTransitionV0, + }; + use crate::state_transition::batch_transition::{BatchTransition, BatchTransitionV0}; + use crate::state_transition::data_contract_create_transition::{ + DataContractCreateTransition, DataContractCreateTransitionV0, + }; + use crate::state_transition::data_contract_update_transition::{ + DataContractUpdateTransition, DataContractUpdateTransitionV0, + }; + use crate::state_transition::identity_create_transition::v0::IdentityCreateTransitionV0; + use crate::state_transition::identity_create_transition::IdentityCreateTransition; + use crate::state_transition::identity_topup_transition::v0::IdentityTopUpTransitionV0; + use crate::state_transition::identity_topup_transition::IdentityTopUpTransition; + use crate::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use crate::state_transition::identity_update_transition::IdentityUpdateTransition; + use crate::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use crate::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + use crate::state_transition::shielded_withdrawal_transition::v0::ShieldedWithdrawalTransitionV0; + use crate::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; + use crate::state_transition::unshield_transition::v0::UnshieldTransitionV0; + use crate::state_transition::unshield_transition::UnshieldTransition; + + /// Build a DataContractInSerializationFormat from a crate-private v0 + /// constructor via the public TryFromPlatformVersioned impl and DataContract V1. + fn sample_data_contract_in_serialization_format() -> DataContractInSerializationFormat { + use crate::data_contract::config::v0::DataContractConfigV0; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::v1::DataContractV1; + use crate::data_contract::DataContract; + use platform_version::TryIntoPlatformVersioned; + use std::collections::BTreeMap; + + let contract = DataContract::V1(DataContractV1 { + id: Identifier::from([9u8; 32]), + version: 1, + owner_id: Identifier::from([7u8; 32]), + document_types: BTreeMap::new(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: BTreeMap::new(), + tokens: BTreeMap::new(), + keywords: Vec::new(), + description: None, + }); + + contract + .try_into_platform_versioned(PlatformVersion::latest()) + .expect("expected to serialize a trivial contract") + } + + fn sample_data_contract_create_st() -> StateTransition { + StateTransition::DataContractCreate(DataContractCreateTransition::V0( + DataContractCreateTransitionV0 { + data_contract: sample_data_contract_in_serialization_format(), + identity_nonce: 1, + user_fee_increase: 5, + signature_public_key_id: 2, + signature: BinaryData::new(vec![0xAB; 65]), + }, + )) + } + + fn sample_data_contract_update_st() -> StateTransition { + StateTransition::DataContractUpdate(DataContractUpdateTransition::V0( + DataContractUpdateTransitionV0 { + identity_contract_nonce: 4, + data_contract: sample_data_contract_in_serialization_format(), + user_fee_increase: 9, + signature_public_key_id: 6, + signature: BinaryData::new(vec![0xCD; 65]), + }, + )) + } + + fn sample_batch_st_with_delete() -> StateTransition { + let base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([1u8; 32]), + identity_contract_nonce: 3, + document_type_name: "preorder".to_string(), + data_contract_id: Identifier::from([2u8; 32]), + }); + let delete = + DocumentTransition::Delete(DocumentDeleteTransition::V0(DocumentDeleteTransitionV0 { + base, + })); + StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { + owner_id: Identifier::from([8u8; 32]), + transitions: vec![delete], + user_fee_increase: 2, + signature_public_key_id: 7, + signature: BinaryData::new(vec![0xEE; 65]), + })) + } + + fn sample_batch_st_empty() -> StateTransition { + StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { + owner_id: Identifier::from([1u8; 32]), + transitions: vec![], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::new(vec![]), + })) + } + + fn sample_identity_create_st() -> StateTransition { + StateTransition::IdentityCreate(IdentityCreateTransition::V0(IdentityCreateTransitionV0 { + identity_id: Identifier::from([3u8; 32]), + ..Default::default() + })) + } + + fn sample_identity_top_up_st() -> StateTransition { + StateTransition::IdentityTopUp(IdentityTopUpTransition::V0(IdentityTopUpTransitionV0 { + identity_id: Identifier::from([4u8; 32]), + ..Default::default() + })) + } + + fn sample_identity_update_st() -> StateTransition { + StateTransition::IdentityUpdate(IdentityUpdateTransition::V0(IdentityUpdateTransitionV0 { + identity_id: Identifier::from([5u8; 32]), + revision: 1, + nonce: 2, + add_public_keys: vec![], + disable_public_keys: vec![], + user_fee_increase: 11, + signature_public_key_id: 33, + signature: BinaryData::new(vec![0xFF; 65]), + })) + } + + fn sample_unshield_st() -> StateTransition { + StateTransition::Unshield(UnshieldTransition::V0(UnshieldTransitionV0 { + output_address: Default::default(), + actions: vec![], + unshielding_amount: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + })) + } + + fn sample_shielded_transfer_st() -> StateTransition { + StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( + ShieldedTransferTransitionV0 { + actions: vec![], + value_balance: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + }, + )) + } + + fn sample_shielded_withdrawal_st() -> StateTransition { + use crate::identity::core_script::CoreScript; + use crate::withdrawal::Pooling; + StateTransition::ShieldedWithdrawal(ShieldedWithdrawalTransition::V0( + ShieldedWithdrawalTransitionV0 { + actions: vec![], + unshielding_amount: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: CoreScript::from_bytes(vec![]), + }, + )) + } + + // --- name() covers all previously-untested arms, including the nested + // match for Batch variants. --- + #[test] + fn test_name_for_newly_covered_variants() { + assert_eq!( + sample_data_contract_create_st().name(), + "DataContractCreate" + ); + assert_eq!( + sample_data_contract_update_st().name(), + "DataContractUpdate" + ); + assert_eq!(sample_identity_create_st().name(), "IdentityCreate"); + assert_eq!(sample_identity_top_up_st().name(), "IdentityTopUp"); + assert_eq!(sample_identity_update_st().name(), "IdentityUpdate"); + assert_eq!(sample_unshield_st().name(), "Unshield"); + assert_eq!(sample_shielded_transfer_st().name(), "ShieldedTransfer"); + assert_eq!(sample_shielded_withdrawal_st().name(), "ShieldedWithdrawal"); + + // Batch with a single Delete – exercises the nested DocumentTransition + // match arm in `name()`. + let batch_name = sample_batch_st_with_delete().name(); + assert_eq!(batch_name, "DocumentsBatch([Delete])"); + + // Empty batch – still renders, with an empty list. + let empty_name = sample_batch_st_empty().name(); + assert_eq!(empty_name, "DocumentsBatch([])"); + } + + // --- state_transition_type covers the call_method! dispatch. --- + #[test] + fn test_state_transition_type_for_newly_covered_variants() { + assert_eq!( + sample_data_contract_create_st().state_transition_type(), + StateTransitionType::DataContractCreate + ); + assert_eq!( + sample_data_contract_update_st().state_transition_type(), + StateTransitionType::DataContractUpdate + ); + assert_eq!( + sample_batch_st_with_delete().state_transition_type(), + StateTransitionType::Batch + ); + assert_eq!( + sample_identity_create_st().state_transition_type(), + StateTransitionType::IdentityCreate + ); + assert_eq!( + sample_identity_top_up_st().state_transition_type(), + StateTransitionType::IdentityTopUp + ); + assert_eq!( + sample_identity_update_st().state_transition_type(), + StateTransitionType::IdentityUpdate + ); + assert_eq!( + sample_unshield_st().state_transition_type(), + StateTransitionType::Unshield + ); + assert_eq!( + sample_shielded_transfer_st().state_transition_type(), + StateTransitionType::ShieldedTransfer + ); + assert_eq!( + sample_shielded_withdrawal_st().state_transition_type(), + StateTransitionType::ShieldedWithdrawal + ); + } + + // --- active_version_range uses different branches per transition + // "group". Exercises the contract-format V1 branch for + // DataContractCreate/Update (9..=LATEST), the BatchTransitionV0 branch + // (ALL_VERSIONS), and the shielded range (12..=LATEST). + #[test] + fn test_active_version_range_contract_and_shielded_branches() { + // DataContractCreate/Update on PlatformVersion::latest use the V1 + // contract serialization format, which restricts active range. + let contract_v1_range = 9..=LATEST_VERSION; + assert_eq!( + sample_data_contract_create_st().active_version_range(), + contract_v1_range + ); + let contract_v1_range = 9..=LATEST_VERSION; + assert_eq!( + sample_data_contract_update_st().active_version_range(), + contract_v1_range + ); + // BatchTransition::V0 → ALL_VERSIONS + assert_eq!( + sample_batch_st_with_delete().active_version_range(), + ALL_VERSIONS + ); + // IdentityCreate/TopUp/Update are ALL_VERSIONS. + assert_eq!( + sample_identity_create_st().active_version_range(), + ALL_VERSIONS + ); + assert_eq!( + sample_identity_top_up_st().active_version_range(), + ALL_VERSIONS + ); + assert_eq!( + sample_identity_update_st().active_version_range(), + ALL_VERSIONS + ); + // Shielded variants report a shielded range (12..=LATEST_VERSION). + let shielded_range = 12..=LATEST_VERSION; + assert_eq!( + sample_shielded_transfer_st().active_version_range(), + shielded_range.clone() + ); + assert_eq!( + sample_unshield_st().active_version_range(), + shielded_range.clone() + ); + assert_eq!( + sample_shielded_withdrawal_st().active_version_range(), + shielded_range + ); + } + + // --- is_identity_signed exercises the inverted-match logic for the + // shielded / identity-create / topup variants. --- + #[test] + fn test_is_identity_signed_false_for_identity_create_topup_and_shielded() { + assert!(!sample_identity_create_st().is_identity_signed()); + assert!(!sample_identity_top_up_st().is_identity_signed()); + assert!(!sample_unshield_st().is_identity_signed()); + assert!(!sample_shielded_transfer_st().is_identity_signed()); + assert!(!sample_shielded_withdrawal_st().is_identity_signed()); + } + + // --- signature accessor for each arm that returns Some/None; previously + // only IdentityCreditTransfer / MasternodeVote / IdentityCreditWithdrawal + // were covered. + #[test] + fn test_signature_accessor_for_other_variants() { + // Some(_) arms + assert_eq!( + sample_data_contract_create_st().signature().unwrap().len(), + 65 + ); + assert_eq!( + sample_data_contract_update_st().signature().unwrap().len(), + 65 + ); + assert_eq!(sample_batch_st_with_delete().signature().unwrap().len(), 65); + assert_eq!(sample_identity_update_st().signature().unwrap().len(), 65); + + // None arms for address / shielded variants. + assert!(sample_unshield_st().signature().is_none()); + assert!(sample_shielded_transfer_st().signature().is_none()); + assert!(sample_shielded_withdrawal_st().signature().is_none()); + } + + // --- owner_id accessor for each arm. + #[test] + fn test_owner_id_accessor_for_other_variants() { + assert_eq!( + sample_data_contract_create_st().owner_id(), + Some(Identifier::from([7u8; 32])) + ); + assert_eq!( + sample_data_contract_update_st().owner_id(), + Some(Identifier::from([7u8; 32])) + ); + assert_eq!( + sample_batch_st_with_delete().owner_id(), + Some(Identifier::from([8u8; 32])) + ); + assert_eq!( + sample_identity_update_st().owner_id(), + Some(Identifier::from([5u8; 32])) + ); + // These variants unconditionally return None. + assert!(sample_unshield_st().owner_id().is_none()); + assert!(sample_shielded_transfer_st().owner_id().is_none()); + assert!(sample_shielded_withdrawal_st().owner_id().is_none()); + } + + // --- user_fee_increase accessor — includes arms that return 0 + // unconditionally (shielded/masternode) vs the variants' stored value. + #[test] + fn test_user_fee_increase_for_newly_covered_variants() { + assert_eq!(sample_data_contract_create_st().user_fee_increase(), 5); + assert_eq!(sample_data_contract_update_st().user_fee_increase(), 9); + assert_eq!(sample_batch_st_with_delete().user_fee_increase(), 2); + assert_eq!(sample_identity_update_st().user_fee_increase(), 11); + // Unconditionally 0 for shielded. + assert_eq!(sample_shielded_transfer_st().user_fee_increase(), 0); + assert_eq!(sample_shielded_withdrawal_st().user_fee_increase(), 0); + assert_eq!(sample_unshield_st().user_fee_increase(), 0); + } + + // --- set_user_fee_increase for the no-op shielded arms and for the + // transitions that actually do store the value. + #[test] + fn test_set_user_fee_increase_for_newly_covered_variants() { + let mut st = sample_data_contract_create_st(); + st.set_user_fee_increase(42); + assert_eq!(st.user_fee_increase(), 42); + + let mut st = sample_data_contract_update_st(); + st.set_user_fee_increase(13); + assert_eq!(st.user_fee_increase(), 13); + + let mut st = sample_batch_st_with_delete(); + st.set_user_fee_increase(101); + assert_eq!(st.user_fee_increase(), 101); + + let mut st = sample_identity_update_st(); + st.set_user_fee_increase(77); + assert_eq!(st.user_fee_increase(), 77); + + // Shielded no-ops: value stays 0. + let mut shielded = sample_shielded_transfer_st(); + shielded.set_user_fee_increase(99); + assert_eq!(shielded.user_fee_increase(), 0); + + let mut withdrawal = sample_shielded_withdrawal_st(); + withdrawal.set_user_fee_increase(99); + assert_eq!(withdrawal.user_fee_increase(), 0); + + let mut unshield = sample_unshield_st(); + unshield.set_user_fee_increase(99); + assert_eq!(unshield.user_fee_increase(), 0); + } + + // --- set_signature: exercises the `true` arms we didn't test before + // (DataContractCreate/Update/Batch/IdentityUpdate) and the `false` arms + // (shielded transitions). + #[test] + fn test_set_signature_false_for_shielded_and_identity_create_topup() { + // `false` arms: shield*, shielded*, unshield, address* (no-op, returns false). + let mut st = sample_unshield_st(); + assert!(!st.set_signature(BinaryData::new(vec![0xAB; 65]))); + let mut st = sample_shielded_transfer_st(); + assert!(!st.set_signature(BinaryData::new(vec![0xAB; 65]))); + let mut st = sample_shielded_withdrawal_st(); + assert!(!st.set_signature(BinaryData::new(vec![0xAB; 65]))); + } + + #[test] + fn test_set_signature_true_for_newly_covered_variants() { + let mut st = sample_data_contract_create_st(); + assert!(st.set_signature(BinaryData::new(vec![0x11; 65]))); + assert_eq!(st.signature().unwrap().as_slice(), &[0x11; 65]); + + let mut st = sample_data_contract_update_st(); + assert!(st.set_signature(BinaryData::new(vec![0x22; 65]))); + assert_eq!(st.signature().unwrap().as_slice(), &[0x22; 65]); + + let mut st = sample_batch_st_with_delete(); + assert!(st.set_signature(BinaryData::new(vec![0x33; 65]))); + assert_eq!(st.signature().unwrap().as_slice(), &[0x33; 65]); + + let mut st = sample_identity_update_st(); + assert!(st.set_signature(BinaryData::new(vec![0x44; 65]))); + assert_eq!(st.signature().unwrap().as_slice(), &[0x44; 65]); + } + + // --- signature_public_key_id: identity-signed arms return Some, others + // (shielded/identity-create/topup/address) return None. + #[test] + fn test_signature_public_key_id_returns_none_for_non_signed() { + // IdentityCreate / IdentityTopUp / shielded / address variants are all + // "not identity-signed" and return None. + assert!(sample_identity_create_st() + .signature_public_key_id() + .is_none()); + assert!(sample_identity_top_up_st() + .signature_public_key_id() + .is_none()); + assert!(sample_unshield_st().signature_public_key_id().is_none()); + assert!(sample_shielded_transfer_st() + .signature_public_key_id() + .is_none()); + assert!(sample_shielded_withdrawal_st() + .signature_public_key_id() + .is_none()); + } + + #[test] + fn test_signature_public_key_id_for_signed_variants() { + assert_eq!( + sample_data_contract_create_st().signature_public_key_id(), + Some(2) + ); + assert_eq!( + sample_data_contract_update_st().signature_public_key_id(), + Some(6) + ); + assert_eq!( + sample_batch_st_with_delete().signature_public_key_id(), + Some(7) + ); + assert_eq!( + sample_identity_update_st().signature_public_key_id(), + Some(33) + ); + } + + // --- set_signature_public_key_id: no-op for IdentityCreate/TopUp and + // shielded variants; updates for identity-signed variants. --- + #[test] + fn test_set_signature_public_key_id_noop_for_non_signed() { + // These variants are not identity-signed; setter is a no-op in the + // call_method_identity_signed! macro. + let mut st = sample_identity_create_st(); + st.set_signature_public_key_id(100); + assert_eq!(st.signature_public_key_id(), None); + + let mut st = sample_identity_top_up_st(); + st.set_signature_public_key_id(100); + assert_eq!(st.signature_public_key_id(), None); + + let mut st = sample_unshield_st(); + st.set_signature_public_key_id(100); + assert_eq!(st.signature_public_key_id(), None); + } + + #[test] + fn test_set_signature_public_key_id_updates_for_signed_variants() { + let mut st = sample_data_contract_create_st(); + st.set_signature_public_key_id(42); + assert_eq!(st.signature_public_key_id(), Some(42)); + + let mut st = sample_batch_st_with_delete(); + st.set_signature_public_key_id(43); + assert_eq!(st.signature_public_key_id(), Some(43)); + + let mut st = sample_identity_update_st(); + st.set_signature_public_key_id(44); + assert_eq!(st.signature_public_key_id(), Some(44)); + } + + // --- required_number_of_private_keys defaults to 1 for "signed" variants + // and 0 for shielded ones. + #[test] + fn test_required_number_of_private_keys_various_variants() { + assert_eq!( + sample_data_contract_create_st().required_number_of_private_keys(), + 1 + ); + assert_eq!( + sample_data_contract_update_st().required_number_of_private_keys(), + 1 + ); + assert_eq!( + sample_batch_st_with_delete().required_number_of_private_keys(), + 1 + ); + assert_eq!( + sample_identity_update_st().required_number_of_private_keys(), + 1 + ); + assert_eq!( + sample_identity_create_st().required_number_of_private_keys(), + 1 + ); + // Shielded variants return 0 unconditionally. + assert_eq!( + sample_shielded_transfer_st().required_number_of_private_keys(), + 0 + ); + assert_eq!( + sample_shielded_withdrawal_st().required_number_of_private_keys(), + 0 + ); + assert_eq!(sample_unshield_st().required_number_of_private_keys(), 0); + } + + // --- inputs(): None for all these variants (covers the big + // wildcard/None arm in the match). + #[test] + fn test_inputs_none_for_many_variants() { + assert!(sample_data_contract_create_st().inputs().is_none()); + assert!(sample_data_contract_update_st().inputs().is_none()); + assert!(sample_batch_st_with_delete().inputs().is_none()); + assert!(sample_identity_create_st().inputs().is_none()); + assert!(sample_identity_top_up_st().inputs().is_none()); + assert!(sample_identity_update_st().inputs().is_none()); + // Shielded variants also return None for inputs(). + assert!(sample_unshield_st().inputs().is_none()); + assert!(sample_shielded_transfer_st().inputs().is_none()); + assert!(sample_shielded_withdrawal_st().inputs().is_none()); + } + + // --- optional_asset_lock_proof: None for everything that isn't + // IdentityCreate / IdentityTopUp / ShieldFromAssetLock. The IdentityCreate + // default contains the asset lock proof field, so this forwards to its + // implementation. + #[test] + fn test_optional_asset_lock_proof_returns_none_for_wildcard_arms() { + assert!(sample_data_contract_create_st() + .optional_asset_lock_proof() + .is_none()); + assert!(sample_data_contract_update_st() + .optional_asset_lock_proof() + .is_none()); + assert!(sample_batch_st_with_delete() + .optional_asset_lock_proof() + .is_none()); + assert!(sample_identity_update_st() + .optional_asset_lock_proof() + .is_none()); + assert!(sample_unshield_st().optional_asset_lock_proof().is_none()); + assert!(sample_shielded_transfer_st() + .optional_asset_lock_proof() + .is_none()); + assert!(sample_shielded_withdrawal_st() + .optional_asset_lock_proof() + .is_none()); + } + + // --- required_asset_lock_balance_for_processing_start returns an + // CorruptedCodeExecution error for non asset-lock variants. Exercise + // additional arms beyond what the original transfer test covered. + #[test] + fn test_required_asset_lock_balance_errors_for_other_non_asset_lock_variants() { + let platform_version = PlatformVersion::latest(); + + let cases: Vec<(&str, StateTransition)> = vec![ + ("DataContractCreate", sample_data_contract_create_st()), + ("DataContractUpdate", sample_data_contract_update_st()), + ("Batch", sample_batch_st_with_delete()), + ("IdentityUpdate", sample_identity_update_st()), + ("MasternodeVote", sample_masternode_vote_st()), + ("Unshield", sample_unshield_st()), + ("ShieldedTransfer", sample_shielded_transfer_st()), + ("ShieldedWithdrawal", sample_shielded_withdrawal_st()), + ]; + + for (label, st) in cases { + let err = st + .required_asset_lock_balance_for_processing_start(platform_version) + .expect_err(&format!("expected error for {label}")); + match err { + ProtocolError::CorruptedCodeExecution(msg) => { + assert!( + msg.contains("is not an asset lock transaction"), + "unexpected error for {label}: {msg}" + ); + } + other => panic!("expected CorruptedCodeExecution for {label}, got {other:?}"), + } + } + } + + // --- unique_identifiers: covers the call_method! dispatch for arms + // beyond credit transfer. Each variant's `unique_identifiers` + // implementation returns a non-empty vector; the individual identifier + // strings may be empty for some variants whose IDs are encoded as empty + // (this method simply shouldn't panic or short-circuit). + #[test] + fn test_unique_identifiers_non_empty_for_other_variants() { + for st in [ + sample_data_contract_create_st(), + sample_data_contract_update_st(), + sample_batch_st_with_delete(), + sample_identity_create_st(), + sample_identity_top_up_st(), + sample_identity_update_st(), + ] { + let ids = st.unique_identifiers(); + assert!(!ids.is_empty(), "unique_identifiers should not be empty"); + } + } + + // --- security_level_requirement returns None for identity-create/topup + // and for every shielded/address variant. This hits the None arms in + // call_getter_method_identity_signed!. + #[test] + fn test_security_level_requirement_returns_none_for_non_signed_variants() { + let purpose = Purpose::AUTHENTICATION; + assert!(sample_identity_create_st() + .security_level_requirement(purpose) + .is_none()); + assert!(sample_identity_top_up_st() + .security_level_requirement(purpose) + .is_none()); + assert!(sample_unshield_st() + .security_level_requirement(purpose) + .is_none()); + assert!(sample_shielded_transfer_st() + .security_level_requirement(purpose) + .is_none()); + assert!(sample_shielded_withdrawal_st() + .security_level_requirement(purpose) + .is_none()); + } + + #[test] + fn test_purpose_requirement_returns_none_for_non_signed_variants() { + assert!(sample_identity_create_st().purpose_requirement().is_none()); + assert!(sample_identity_top_up_st().purpose_requirement().is_none()); + assert!(sample_unshield_st().purpose_requirement().is_none()); + assert!(sample_shielded_transfer_st() + .purpose_requirement() + .is_none()); + assert!(sample_shielded_withdrawal_st() + .purpose_requirement() + .is_none()); + } + + // --- From impls: each From → StateTransition uses `derive_more::From`. + #[test] + fn test_from_outer_data_contract_create_into_state_transition() { + let outer: DataContractCreateTransition = + DataContractCreateTransition::V0(DataContractCreateTransitionV0 { + data_contract: sample_data_contract_in_serialization_format(), + identity_nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + }); + let st: StateTransition = outer.into(); + assert!(matches!(st, StateTransition::DataContractCreate(_))); + } + + #[test] + fn test_from_outer_data_contract_update_into_state_transition() { + let outer: DataContractUpdateTransition = + DataContractUpdateTransition::V0(DataContractUpdateTransitionV0 { + identity_contract_nonce: 2, + data_contract: sample_data_contract_in_serialization_format(), + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + }); + let st: StateTransition = outer.into(); + assert!(matches!(st, StateTransition::DataContractUpdate(_))); + } + + #[test] + fn test_from_outer_batch_into_state_transition() { + let outer: BatchTransition = BatchTransition::V0(BatchTransitionV0::default()); + let st: StateTransition = outer.into(); + assert!(matches!(st, StateTransition::Batch(_))); + } + + #[test] + fn test_from_outer_identity_create_into_state_transition() { + let outer: IdentityCreateTransition = + IdentityCreateTransition::V0(IdentityCreateTransitionV0::default()); + let st: StateTransition = outer.into(); + assert!(matches!(st, StateTransition::IdentityCreate(_))); + } + + #[test] + fn test_from_outer_identity_update_into_state_transition() { + let outer: IdentityUpdateTransition = + IdentityUpdateTransition::V0(IdentityUpdateTransitionV0::default()); + let st: StateTransition = outer.into(); + assert!(matches!(st, StateTransition::IdentityUpdate(_))); + } + + // --- transaction_id + clone for additional variants — triggers the + // serialize path for each arm. + #[test] + fn test_transaction_id_and_clone_for_identity_update() { + let st = sample_identity_update_st(); + let id_a = st.transaction_id().expect("hash should succeed"); + let cloned = st.clone(); + let id_b = cloned.transaction_id().expect("hash should succeed"); + assert_eq!(id_a, id_b); + assert_eq!(id_a.len(), 32); + } + + #[test] + fn test_transaction_id_and_clone_for_data_contract_create() { + let st = sample_data_contract_create_st(); + let id_a = st.transaction_id().expect("hash should succeed"); + let cloned = st.clone(); + let id_b = cloned.transaction_id().expect("hash should succeed"); + assert_eq!(id_a, id_b); + assert_eq!(id_a.len(), 32); + } + + // --- serialize round-trip for variants beyond credit transfer. --- + #[test] + fn test_serialize_roundtrip_identity_update() { + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + let original = sample_identity_update_st(); + let bytes = + PlatformSerializable::serialize_to_bytes(&original).expect("serialize should succeed"); + let restored = + StateTransition::deserialize_from_bytes(&bytes).expect("deserialize should succeed"); + assert_eq!(original, restored); + } + + #[test] + fn test_serialize_roundtrip_data_contract_update() { + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + let original = sample_data_contract_update_st(); + let bytes = + PlatformSerializable::serialize_to_bytes(&original).expect("serialize should succeed"); + let restored = + StateTransition::deserialize_from_bytes(&bytes).expect("deserialize should succeed"); + assert_eq!(original, restored); + } + + #[test] + fn test_serialize_roundtrip_batch_empty() { + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + let original = sample_batch_st_empty(); + let bytes = + PlatformSerializable::serialize_to_bytes(&original).expect("serialize should succeed"); + let restored = + StateTransition::deserialize_from_bytes(&bytes).expect("deserialize should succeed"); + assert_eq!(original, restored); + } + + // --- deserialize_from_bytes_in_version error path: craft bytes for a + // variant whose `active_version_range()` starts at 11 or 12 and then + // attempt to deserialize them with a PlatformVersion whose protocol + // version is below that range. Exercises the + // `StateTransitionIsNotActiveError` arm. + // --- + #[cfg(all(feature = "state-transitions", feature = "validation"))] + #[test] + fn test_deserialize_from_bytes_in_version_returns_not_active_error() { + use crate::serialization::PlatformSerializable; + + // ShieldedTransfer has active_version_range = 12..=LATEST_VERSION. + let original = sample_shielded_transfer_st(); + let bytes = + PlatformSerializable::serialize_to_bytes(&original).expect("serialize succeeds"); + + // Find a real PlatformVersion whose protocol_version is < 12 so the + // range check rejects it. PlatformVersion::get(1) corresponds to + // protocol version 1 which is guaranteed below any shielded range. + let low_version = PlatformVersion::get(1).expect("platform version 1 exists"); + assert!( + low_version.protocol_version < 12, + "expected sub-12 version for this test, got {}", + low_version.protocol_version + ); + + let err = StateTransition::deserialize_from_bytes_in_version(&bytes, low_version) + .expect_err("expected StateTransitionIsNotActiveError for sub-12 protocol"); + match err { + ProtocolError::StateTransitionError( + crate::state_transition::errors::StateTransitionError::StateTransitionIsNotActiveError { + state_transition_type, + active_version_range, + current_protocol_version, + }, + ) => { + assert_eq!(state_transition_type, "ShieldedTransfer"); + assert_eq!(current_protocol_version, low_version.protocol_version); + assert!(active_version_range.start() >= &12); + } + other => panic!("expected StateTransitionIsNotActiveError, got {other:?}"), + } + } } diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v0/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v0/mod.rs index 25d0abc4833..0ecc481a2bb 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v0/mod.rs @@ -78,3 +78,217 @@ impl Drive { Ok(drive_operations) } } + +#[cfg(test)] +mod tests { + use crate::error::drive::DriveError; + use crate::error::identity::IdentityError; + use crate::error::Error; + use crate::fees::op::LowLevelDriveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::balances::credits::MAX_CREDITS; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + /// Creates an existing specialized balance at `amount` credits. + fn seed_balance( + drive: &crate::drive::Drive, + id: Identifier, + amount: u64, + platform_version: &PlatformVersion, + ) { + drive + .add_prefunded_specialized_balance(id, amount, None, platform_version) + .expect("seed balance"); + } + + #[test] + fn new_balance_uses_insert_or_replace_op_and_creates_entry() { + // First-time insert branch: previous balance is None, so the op must + // be an insert_or_replace_op and the fetched balance must equal `amount`. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([7u8; 32]); + + let mut estimated = None; + let ops = drive + .add_prefunded_specialized_balance_operations_v0( + id, + 1_000, + &mut estimated, + None, + platform_version, + ) + .expect("create op for new balance"); + // At least one operation (the GroveDB insert op); the preceding read may + // push additional cost operations into the drive_operations vec. + assert!(!ops.is_empty()); + + // Apply the operations that the function actually returned (rather + // than going through the seed helper) so the insert branch's + // GroveDB write is what we verify. + let mut drive_ops = vec![]; + drive + .apply_batch_low_level_drive_operations( + None, + None, + ops, + &mut drive_ops, + &platform_version.drive, + ) + .expect("apply add ops"); + + let fetched = drive + .fetch_prefunded_specialized_balance(id.to_buffer(), None, platform_version) + .expect("fetch balance"); + assert_eq!(fetched, Some(1_000)); + } + + #[test] + fn existing_balance_accumulates_across_two_adds() { + // Second add exercises the `had_previous_balance == true` branch, and + // the replace_op path inside v0. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([8u8; 32]); + + seed_balance(&drive, id, 500, platform_version); + seed_balance(&drive, id, 250, platform_version); + + let fetched = drive + .fetch_prefunded_specialized_balance(id.to_buffer(), None, platform_version) + .expect("fetch balance"); + assert_eq!(fetched, Some(750)); + } + + #[test] + fn add_amount_causing_u64_overflow_returns_critical_corrupted_state() { + // `previous + amount` overflows u64 when previous = u64::MAX - 1 and amount = 2, + // so `checked_add` returns None and we expect CriticalCorruptedState error. + // We synthesize this by first using direct deduction: seed to max_credits - 1, then + // attempt to add enough to overflow. MAX_CREDITS = i64::MAX (< u64::MAX) so the + // `>= MAX_CREDITS` check triggers first for amounts in the valid range; we instead + // verify the overflow branch by a pure-computation simulation using u64::MAX. + // Here we exercise the MAX_CREDITS overflow branch (more realistic in the wild): + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([9u8; 32]); + + // Seed a balance that is exactly MAX_CREDITS - 1. + seed_balance(&drive, id, MAX_CREDITS - 1, platform_version); + + // Adding 1 more pushes new_total to MAX_CREDITS which triggers the + // `>= MAX_CREDITS` guard and returns CriticalBalanceOverflow. + let mut estimated = None; + let err = drive + .add_prefunded_specialized_balance_operations_v0( + id, + 1, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected overflow guard to trip"); + assert!( + matches!( + err, + Error::Identity(IdentityError::CriticalBalanceOverflow(_)) + ), + "expected CriticalBalanceOverflow, got: {:?}", + err + ); + } + + #[test] + fn add_amount_causing_checked_add_overflow_is_critical_corrupted_state() { + // Here we ask for an amount that would cause checked_add itself to + // return None. u64::MAX added to any non-zero existing balance does this. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([10u8; 32]); + + // Seed 1 credit, then add u64::MAX. + seed_balance(&drive, id, 1, platform_version); + + let mut estimated = None; + let err = drive + .add_prefunded_specialized_balance_operations_v0( + id, + u64::MAX, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected checked_add overflow error"); + assert!( + matches!(err, Error::Drive(DriveError::CriticalCorruptedState(_))), + "expected CriticalCorruptedState, got: {:?}", + err + ); + } + + #[test] + fn estimation_path_produces_estimation_entries() { + // When estimated_costs_only_with_layer_info is Some(_), add_estimation_costs_* must be + // invoked and populate the map even for a fresh (non-existent) identifier. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([11u8; 32]); + + let mut estimated = Some(std::collections::HashMap::new()); + let _ops = drive + .add_prefunded_specialized_balance_operations_v0( + id, + 42, + &mut estimated, + None, + platform_version, + ) + .expect("create op with estimation"); + + let map = estimated.expect("estimated map present"); + assert!( + !map.is_empty(), + "expected add_estimation_costs_* to populate layer info" + ); + } + + #[test] + fn add_zero_to_nonexistent_creates_zero_entry() { + // An add of 0 to a non-existent balance takes the insert_or_replace branch + // and must not error. checked_add(0) is always Some, new_total = 0 < MAX_CREDITS. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([12u8; 32]); + + let mut estimated = None; + let ops = drive + .add_prefunded_specialized_balance_operations_v0( + id, + 0, + &mut estimated, + None, + platform_version, + ) + .expect("zero add"); + + // Apply the emitted ops and confirm the entry exists with value 0 — + // this verifies the insert_or_replace_op was emitted correctly rather + // than merely that *some* ops were produced. + let mut drive_ops = vec![]; + drive + .apply_batch_low_level_drive_operations( + None, + None, + ops, + &mut drive_ops, + &platform_version.drive, + ) + .expect("apply zero add ops"); + + let fetched = drive + .fetch_prefunded_specialized_balance(id.to_buffer(), None, platform_version) + .expect("fetch balance"); + assert_eq!(fetched, Some(0)); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v1/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v1/mod.rs index e691b12efe6..a65eb8f7564 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/add_prefunded_specialized_balance_operations/v1/mod.rs @@ -89,3 +89,122 @@ impl Drive { Ok(drive_operations) } } + +#[cfg(test)] +mod tests { + use crate::error::drive::DriveError; + use crate::error::identity::IdentityError; + use crate::error::Error; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::balances::credits::MAX_CREDITS; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + use std::collections::HashMap; + + #[test] + fn stateless_query_branch_runs_without_errors_for_missing_id() { + // The v1 implementation switches to StatelessDirectQuery when estimation + // mode is on. Exercise this branch on a non-existent balance: the lookup + // must succeed but return None, and the final op must still be generated. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([42u8; 32]); + + let mut estimated = Some(HashMap::new()); + let ops = drive + .add_prefunded_specialized_balance_operations_v1( + id, + 1_234, + &mut estimated, + None, + platform_version, + ) + .expect("stateless branch should succeed"); + // At least the insert_or_replace op is present (read cost ops may also be). + assert!(!ops.is_empty()); + + // Estimation map must be populated. + assert!(!estimated.unwrap().is_empty()); + } + + #[test] + fn stateful_branch_increments_balance() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([43u8; 32]); + + // First add to seed a balance (calls through v0/v1 picked by platform version). + drive + .add_prefunded_specialized_balance(id, 100, None, platform_version) + .expect("seed"); + + let mut estimated = None; + let _ops = drive + .add_prefunded_specialized_balance_operations_v1( + id, + 55, + &mut estimated, + None, + platform_version, + ) + .expect("v1 stateful add"); + } + + #[test] + fn v1_rejects_total_at_or_above_max_credits() { + // v1 has the same >= MAX_CREDITS guard as v0. Seed MAX_CREDITS - 1, add 1. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([44u8; 32]); + + drive + .add_prefunded_specialized_balance(id, MAX_CREDITS - 1, None, platform_version) + .expect("seed near-max"); + + let mut estimated = None; + let err = drive + .add_prefunded_specialized_balance_operations_v1( + id, + 1, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected overflow guard"); + assert!( + matches!( + err, + Error::Identity(IdentityError::CriticalBalanceOverflow(_)) + ), + "expected CriticalBalanceOverflow, got: {:?}", + err + ); + } + + #[test] + fn v1_rejects_checked_add_overflow() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([45u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 1, None, platform_version) + .expect("seed 1"); + + let mut estimated = None; + let err = drive + .add_prefunded_specialized_balance_operations_v1( + id, + u64::MAX, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected checked_add overflow"); + assert!( + matches!(err, Error::Drive(DriveError::CriticalCorruptedState(_))), + "expected CriticalCorruptedState, got: {:?}", + err + ); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v0/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v0/mod.rs index a6adec95841..ef3a75fc580 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v0/mod.rs @@ -69,3 +69,137 @@ impl Drive { Ok(drive_operations) } } + +#[cfg(test)] +mod tests { + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + #[test] + fn deduct_from_missing_balance_returns_does_not_exist_error() { + // Error branch: previous balance is None -> PrefundedSpecializedBalanceDoesNotExist. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([1u8; 32]); + + let mut estimated = None; + let err = drive + .deduct_from_prefunded_specialized_balance_operations_v0( + id, + 10, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected missing balance error"); + assert!( + matches!( + err, + Error::Drive(DriveError::PrefundedSpecializedBalanceDoesNotExist(_)) + ), + "expected PrefundedSpecializedBalanceDoesNotExist, got: {:?}", + err + ); + } + + #[test] + fn deduct_more_than_available_returns_not_enough_error() { + // checked_sub branch: amount > previous -> PrefundedSpecializedBalanceNotEnough. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([2u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 100, None, platform_version) + .expect("seed"); + + let mut estimated = None; + let err = drive + .deduct_from_prefunded_specialized_balance_operations_v0( + id, + 101, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected not enough balance error"); + match err { + Error::Drive(DriveError::PrefundedSpecializedBalanceNotEnough(avail, req)) => { + assert_eq!(avail, 100); + assert_eq!(req, 101); + } + other => panic!( + "expected PrefundedSpecializedBalanceNotEnough, got: {:?}", + other + ), + } + } + + #[test] + fn deduct_exact_amount_leaves_zero_balance() { + // Boundary: deducting exactly the available amount should work, leaving 0. + // We use the low-level _operations_v0 + manual apply because the public + // dispatcher `deduct_from_prefunded_specialized_balance` currently only + // knows version 0 while platform_version.deduct_from_prefunded_specialized_balance = 1. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([3u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 500, None, platform_version) + .expect("seed"); + + let mut estimated = None; + let ops = drive + .deduct_from_prefunded_specialized_balance_operations_v0( + id, + 500, + &mut estimated, + None, + platform_version, + ) + .expect("build deduct ops"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("apply deduct ops"); + + let fetched = drive + .fetch_prefunded_specialized_balance(id.to_buffer(), None, platform_version) + .expect("fetch"); + assert_eq!(fetched, Some(0)); + } + + #[test] + fn estimation_costs_populated_on_estimate_request() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([4u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 1_000, None, platform_version) + .expect("seed"); + + let mut estimated = Some(std::collections::HashMap::new()); + let _ops = drive + .deduct_from_prefunded_specialized_balance_operations_v0( + id, + 100, + &mut estimated, + None, + platform_version, + ) + .expect("deduct with estimation"); + assert!(!estimated.unwrap().is_empty()); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v1/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v1/mod.rs index e059985c0fe..c043e83d78c 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/deduct_from_prefunded_specialized_balance_operations/v1/mod.rs @@ -89,3 +89,91 @@ impl Drive { Ok(drive_operations) } } + +#[cfg(test)] +mod tests { + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + use std::collections::HashMap; + + #[test] + fn v1_deduct_missing_in_stateful_mode_is_error() { + // In v1, with estimated_costs_only = None (stateful), missing balance still errors. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([80u8; 32]); + + let mut estimated = None; + let err = drive + .deduct_from_prefunded_specialized_balance_operations_v1( + id, + 1, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected missing balance error"); + assert!(matches!( + err, + Error::Drive(DriveError::PrefundedSpecializedBalanceDoesNotExist(_)) + )); + } + + #[test] + fn v1_deduct_missing_in_stateless_mode_uses_sentinel_balance() { + // In v1, when estimated_costs_only is Some(_), a missing balance is treated + // as i64::MAX (sentinel) so estimation can proceed without an actual state entry. + // This exercises the None => i64::MAX as u64 branch. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([81u8; 32]); + + let mut estimated = Some(HashMap::new()); + let ops = drive + .deduct_from_prefunded_specialized_balance_operations_v1( + id, + 1, + &mut estimated, + None, + platform_version, + ) + .expect("estimation should succeed even without existing balance"); + assert!(!ops.is_empty()); + assert!(!estimated.unwrap().is_empty()); + } + + #[test] + fn v1_deduct_more_than_available_returns_not_enough() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([82u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 5, None, platform_version) + .expect("seed"); + + let mut estimated = None; + let err = drive + .deduct_from_prefunded_specialized_balance_operations_v1( + id, + 10, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected not enough error"); + match err { + Error::Drive(DriveError::PrefundedSpecializedBalanceNotEnough(avail, req)) => { + assert_eq!(avail, 5); + assert_eq!(req, 10); + } + other => panic!( + "expected PrefundedSpecializedBalanceNotEnough, got: {:?}", + other + ), + } + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/empty_prefunded_specialized_balance_operations/v0/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/empty_prefunded_specialized_balance_operations/v0/mod.rs index d0959b40715..b971adc53c2 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/empty_prefunded_specialized_balance_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/empty_prefunded_specialized_balance_operations/v0/mod.rs @@ -83,3 +83,127 @@ impl Drive { Ok((previous_credits_in_specialized_balance, drive_operations)) } } + +#[cfg(test)] +mod tests { + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::fees::op::LowLevelDriveOperation::GroveOperation; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + use grovedb::batch::GroveOp; + use std::collections::HashMap; + + /// Returns true if the op vec contains any GroveDB delete op. Each test + /// operates on a fresh drive and only touches one balance id, so any + /// delete op present must be the one for our target. + fn has_delete_op(ops: &[crate::fees::op::LowLevelDriveOperation]) -> bool { + ops.iter().any(|op| match op { + GroveOperation(qualified) => { + matches!(qualified.op, GroveOp::Delete | GroveOp::DeleteTree { .. }) + } + _ => false, + }) + } + + #[test] + fn empty_missing_without_error_flag_returns_zero_and_no_delete_op() { + // error_if_does_not_exist = false, missing balance -> return (0, drive_operations-so-far) + // BEFORE pushing the delete op. We explicitly assert the delete op + // was NOT emitted, to protect the early-return branch. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([20u8; 32]); + + let mut estimated = None; + let (credits, ops) = drive + .empty_prefunded_specialized_balance_operations_v0( + id, + false, + &mut estimated, + None, + platform_version, + ) + .expect("empty should succeed with error_if_does_not_exist=false"); + assert_eq!(credits, 0); + assert!( + !has_delete_op(&ops), + "delete op must not be emitted when balance is missing and \ + error_if_does_not_exist=false" + ); + } + + #[test] + fn empty_missing_with_error_flag_returns_does_not_exist() { + // error_if_does_not_exist = true, missing balance -> PrefundedSpecializedBalanceDoesNotExist. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([21u8; 32]); + + let mut estimated = None; + let err = drive + .empty_prefunded_specialized_balance_operations_v0( + id, + true, + &mut estimated, + None, + platform_version, + ) + .expect_err("expected DoesNotExist"); + assert!(matches!( + err, + Error::Drive(DriveError::PrefundedSpecializedBalanceDoesNotExist(_)) + )); + } + + #[test] + fn empty_existing_balance_returns_previous_amount_and_delete_op() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([22u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 7_500, None, platform_version) + .expect("seed"); + + // Drive the empty via the dispatcher so the op is actually applied to state. + let returned_credits = drive + .empty_prefunded_specialized_balance(id, false, None, platform_version) + .expect("empty existing balance"); + assert_eq!(returned_credits, 7_500); + + let fetched = drive + .fetch_prefunded_specialized_balance(id.to_buffer(), None, platform_version) + .expect("fetch after empty"); + assert_eq!(fetched, None, "deleted entry should no longer be found"); + } + + #[test] + fn empty_estimation_path_missing_uses_zero_sentinel_and_emits_delete_op() { + // Missing balance + estimation mode -> None branch falls to the else (previous = 0) path. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([23u8; 32]); + + let mut estimated = Some(HashMap::new()); + let (credits, ops) = drive + .empty_prefunded_specialized_balance_operations_v0( + id, + true, // even with error flag, estimation mode overrides the error + &mut estimated, + None, + platform_version, + ) + .expect("estimation path should not error even with error_if_does_not_exist=true"); + assert_eq!(credits, 0); + // Delete op WAS pushed even in estimation mode — assert its presence + // directly rather than checking that ops is merely non-empty (read / + // cost ops would also satisfy non-empty). + assert!( + has_delete_op(&ops), + "delete op must be emitted in estimation mode" + ); + assert!(!estimated.unwrap().is_empty()); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/fetch/single_balance/v0/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/fetch/single_balance/v0/mod.rs index df91b0682e6..a8811774b07 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/fetch/single_balance/v0/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/fetch/single_balance/v0/mod.rs @@ -120,3 +120,126 @@ impl Drive { } } } + +#[cfg(test)] +mod tests { + use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_voting_path_vec; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + use grovedb::batch::QualifiedGroveDbOp; + use grovedb::Element; + + #[test] + fn fetch_missing_apply_true_returns_none() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let balance = drive + .fetch_prefunded_specialized_balance_v0([0u8; 32], None, platform_version) + .expect("fetch should succeed"); + assert_eq!(balance, None); + } + + #[test] + fn fetch_missing_with_apply_false_returns_some_zero_for_estimation() { + // When apply = false (stateless), a missing key should return Some(0), + // not None, because estimation paths assume the entry exists. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let mut drive_ops = vec![]; + let balance = drive + .fetch_prefunded_specialized_balance_operations_v0( + [7u8; 32], + false, + None, + &mut drive_ops, + platform_version, + ) + .expect("stateless fetch should succeed"); + assert_eq!(balance, Some(0)); + } + + #[test] + fn fetch_existing_balance_roundtrips() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([99u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 1_234_567, None, platform_version) + .expect("seed"); + + let balance = drive + .fetch_prefunded_specialized_balance_v0(id.to_buffer(), None, platform_version) + .expect("fetch"); + assert_eq!(balance, Some(1_234_567)); + } + + #[test] + fn fetch_negative_sum_item_returns_corrupted_element_type_error() { + // Corrupted-state branch: a SumItem with a negative balance is corrupted. + // We write one directly via a batch op with a negative value and expect + // a CorruptedElementType error from the fetch path. + use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([50u8; 32]); + + // Directly insert a negative SumItem bypassing the checked add path. + let op = QualifiedGroveDbOp::insert_or_replace_op( + prefunded_specialized_balances_for_voting_path_vec(), + id.to_vec(), + Element::new_sum_item(-42), + ); + drive + .grove_apply_batch( + crate::util::batch::GroveDbOpBatch::from_operations(vec![op]), + false, + None, + &platform_version.drive, + ) + .expect("apply negative sum item"); + + let err = drive + .fetch_prefunded_specialized_balance_v0(id.to_buffer(), None, platform_version) + .expect_err("expected CorruptedElementType"); + use crate::error::drive::DriveError; + use crate::error::Error; + assert!( + matches!(err, Error::Drive(DriveError::CorruptedElementType(_))), + "expected CorruptedElementType, got: {:?}", + err + ); + } + + #[test] + fn fetch_with_costs_returns_fees_for_existing_balance() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let id = Identifier::from([55u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 42, None, platform_version) + .expect("seed"); + + let (balance, fees) = drive + .fetch_prefunded_specialized_balance_with_costs_v0( + id.to_buffer(), + &block_info, + true, + None, + platform_version, + ) + .expect("fetch with costs"); + assert_eq!(balance, Some(42)); + assert!( + fees.processing_fee > 0 || fees.storage_fee > 0, + "expected non-zero fees" + ); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/prove/single_balance/v0/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/prove/single_balance/v0/mod.rs index 6877a55467b..a7491f80152 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/prove/single_balance/v0/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/prove/single_balance/v0/mod.rs @@ -24,3 +24,39 @@ impl Drive { ) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::identifier::Identifier; + use dpp::version::PlatformVersion; + + #[test] + fn prove_nonexistent_balance_returns_non_empty_proof() { + // Proving a non-existent key must still produce a non-empty proof + // (the proof attests absence). It must not error. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let proof = drive + .prove_prefunded_specialized_balance_v0([0xAAu8; 32], None, platform_version) + .expect("prove non-existent should succeed"); + assert!(!proof.is_empty()); + } + + #[test] + fn prove_existing_balance_returns_non_empty_proof() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let id = Identifier::from([77u8; 32]); + + drive + .add_prefunded_specialized_balance(id, 100, None, platform_version) + .expect("seed"); + + let proof = drive + .prove_prefunded_specialized_balance_v0(id.to_buffer(), None, platform_version) + .expect("prove existing"); + assert!(!proof.is_empty()); + } +} diff --git a/packages/rs-drive/src/drive/shielded/has_anchor/v0/mod.rs b/packages/rs-drive/src/drive/shielded/has_anchor/v0/mod.rs index 4096a7c893a..2cfeb3fcfc1 100644 --- a/packages/rs-drive/src/drive/shielded/has_anchor/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/has_anchor/v0/mod.rs @@ -30,3 +30,66 @@ impl Drive { ) } } + +#[cfg(test)] +mod tests { + use crate::drive::shielded::paths::shielded_credit_pool_anchors_path; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + use grovedb::Element; + + #[test] + fn has_anchor_returns_false_on_empty_tree() { + // Fresh pool's anchors tree is empty -> false. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let mut drive_ops = vec![]; + + let found = drive + .has_shielded_anchor_v0(&[0u8; 32], None, &mut drive_ops, platform_version) + .expect("has_shielded_anchor should succeed on empty tree"); + assert!(!found); + } + + #[test] + fn has_anchor_returns_true_after_direct_insert() { + // Direct insertion into the anchors tree is the fastest way to exercise + // the "present" branch without going through record_anchor_if_changed. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + let grove_version = &platform_version.drive.grove_version; + + let anchor = [0xEEu8; 32]; + drive + .grove + .insert( + &shielded_credit_pool_anchors_path(), + &anchor, + Element::new_item(1u64.to_be_bytes().to_vec()), + None, + Some(&transaction), + grove_version, + ) + .unwrap() + .expect("insert anchor"); + + let mut drive_ops = vec![]; + assert!(drive + .has_shielded_anchor_v0( + &anchor, + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + assert!(!drive + .has_shielded_anchor_v0( + &[0xFFu8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } +} diff --git a/packages/rs-drive/src/drive/shielded/has_nullifier/v0/mod.rs b/packages/rs-drive/src/drive/shielded/has_nullifier/v0/mod.rs index e25dbbd6d20..90fdb41e7e2 100644 --- a/packages/rs-drive/src/drive/shielded/has_nullifier/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/has_nullifier/v0/mod.rs @@ -30,3 +30,80 @@ impl Drive { ) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + + #[test] + fn has_nullifier_returns_false_for_unspent() { + // A fresh pool has no nullifiers -> has_nullifier returns false. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let mut drive_ops = vec![]; + + let found = drive + .has_nullifier_v0(&[1u8; 32], None, &mut drive_ops, platform_version) + .expect("has_nullifier should succeed on empty tree"); + assert!(!found); + } + + #[test] + fn has_nullifier_returns_true_after_insert() { + // After inserting nullifiers, has_nullifier must return true for each + // and false for an unrelated nullifier. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let nullifier_a = [0xAAu8; 32]; + let nullifier_b = [0xBBu8; 32]; + let ops = drive + .insert_nullifiers_v0( + &[nullifier_a, nullifier_b], + 1, + 1000, + Some(&transaction), + platform_version, + ) + .expect("insert_nullifiers"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("apply insert_nullifiers ops"); + + let mut drive_ops = vec![]; + assert!(drive + .has_nullifier_v0( + &nullifier_a, + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + assert!(drive + .has_nullifier_v0( + &nullifier_b, + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + assert!(!drive + .has_nullifier_v0( + &[0xCCu8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } +} diff --git a/packages/rs-drive/src/drive/shielded/insert_note/v0/mod.rs b/packages/rs-drive/src/drive/shielded/insert_note/v0/mod.rs index ebdc1663adf..ff9384e2cf6 100644 --- a/packages/rs-drive/src/drive/shielded/insert_note/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/insert_note/v0/mod.rs @@ -27,3 +27,25 @@ impl Drive { )]) } } + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + + #[test] + fn empty_encrypted_note_still_produces_single_op() { + // Empty encrypted note payload - edge case - still produces one op. + let ops = Drive::insert_note_op_v0([0u8; 32], [0u8; 32], vec![]) + .expect("should build op even for empty encrypted note"); + assert_eq!(ops.len(), 1); + } + + #[test] + fn oversized_encrypted_note_still_produces_single_op() { + // Oversized payload - insert_note_op_v0 itself has no size validation; + // this verifies the function unconditionally returns a single op. + let ops = Drive::insert_note_op_v0([1u8; 32], [2u8; 32], vec![0xAA; 10_000]) + .expect("oversized payload builds op"); + assert_eq!(ops.len(), 1); + } +} diff --git a/packages/rs-drive/src/drive/shielded/insert_nullifiers/v0/mod.rs b/packages/rs-drive/src/drive/shielded/insert_nullifiers/v0/mod.rs index 406257da297..5854b29fa5b 100644 --- a/packages/rs-drive/src/drive/shielded/insert_nullifiers/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/insert_nullifiers/v0/mod.rs @@ -54,3 +54,74 @@ impl Drive { Ok(ops) } } + +#[cfg(test)] +mod tests { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use platform_version::version::PlatformVersion; + + #[test] + fn insert_empty_nullifier_slice_produces_empty_ops_vec() { + // Empty input edge case: no ops should be produced, sync storage should still + // be invoked (it no-ops internally for empty input). + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let ops = drive + .insert_nullifiers_v0(&[], 1, 1_000, Some(&transaction), platform_version) + .expect("empty nullifiers should be allowed"); + assert!(ops.is_empty()); + } + + #[test] + fn insert_single_nullifier_produces_single_op_and_applies() { + // Single nullifier path. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let nullifier = [0x11u8; 32]; + let ops = drive + .insert_nullifiers_v0(&[nullifier], 1, 1_000, Some(&transaction), platform_version) + .expect("insert single"); + assert_eq!(ops.len(), 1); + + // Apply and confirm presence. + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("apply"); + + let mut drive_ops = vec![]; + assert!(drive + .has_nullifier( + &nullifier, + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } + + #[test] + fn insert_many_nullifiers_produces_matching_op_count() { + // Multiple nullifiers - verifies one op per nullifier. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let nullifiers: Vec<[u8; 32]> = (0u8..5).map(|i| [i; 32]).collect(); + let ops = drive + .insert_nullifiers_v0(&nullifiers, 1, 1_000, Some(&transaction), platform_version) + .expect("insert many"); + assert_eq!(ops.len(), 5); + } +} diff --git a/packages/rs-drive/src/drive/shielded/notes_count/v0/mod.rs b/packages/rs-drive/src/drive/shielded/notes_count/v0/mod.rs index e6648466682..63a56806501 100644 --- a/packages/rs-drive/src/drive/shielded/notes_count/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/notes_count/v0/mod.rs @@ -26,3 +26,88 @@ impl Drive { ) } } + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + + #[test] + fn empty_pool_has_zero_notes() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let mut drive_ops = vec![]; + + let count = drive + .shielded_pool_notes_count_v0(None, &mut drive_ops, platform_version) + .expect("count on empty pool"); + assert_eq!(count, 0); + } + + #[test] + fn count_increments_after_single_insert() { + // Inserting one note bumps the count to 1. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let ops = Drive::insert_note_op( + [0xAAu8; 32], + [0x01u8; 32], + vec![0x42u8; 216], + platform_version, + ) + .expect("build note op"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("apply note insert"); + + let mut drive_ops = vec![]; + let count = drive + .shielded_pool_notes_count_v0(None, &mut drive_ops, platform_version) + .expect("count after insert"); + assert_eq!(count, 1); + } + + #[test] + fn count_reflects_multiple_inserts() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Insert 3 distinct notes with distinct cmx/nullifier pairs. + for i in 0u8..3 { + let mut cmx = [0u8; 32]; + cmx[0] = i + 1; + let mut rho = [0u8; 32]; + rho[0] = i + 100; + + let ops = + Drive::insert_note_op(rho, cmx, vec![i; 216], platform_version).expect("build"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("apply"); + } + + let mut drive_ops = vec![]; + let count = drive + .shielded_pool_notes_count_v0(None, &mut drive_ops, platform_version) + .expect("count"); + assert_eq!(count, 3); + } +} diff --git a/packages/rs-drive/src/drive/shielded/nullifiers/types.rs b/packages/rs-drive/src/drive/shielded/nullifiers/types.rs index ce423611c0d..6aa06b6f5af 100644 --- a/packages/rs-drive/src/drive/shielded/nullifiers/types.rs +++ b/packages/rs-drive/src/drive/shielded/nullifiers/types.rs @@ -165,6 +165,100 @@ impl CompactedNullifierChange { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compacted_nullifiers_round_trip_empty() { + // Empty list round-trips cleanly. + let original = CompactedNullifiers::new(vec![]); + let bytes = original.encode().expect("encode empty"); + let decoded = CompactedNullifiers::decode(&bytes).expect("decode empty"); + assert!(decoded.is_empty()); + } + + #[test] + fn compacted_nullifiers_round_trip_many() { + // Non-empty list preserves order and contents. + let inputs: Vec<[u8; 32]> = (0u8..10).map(|i| [i; 32]).collect(); + let original = CompactedNullifiers::new(inputs.clone()); + let bytes = original.encode().expect("encode"); + let decoded = CompactedNullifiers::decode(&bytes).expect("decode"); + assert_eq!(decoded.into_inner(), inputs); + } + + #[test] + fn compacted_nullifiers_decode_garbage_returns_corrupted_serialization() { + // Arbitrary garbage bytes must not produce a valid CompactedNullifiers. + match CompactedNullifiers::decode(&[0xFFu8; 3]) { + Err(Error::Protocol(b)) => match b.as_ref() { + ProtocolError::CorruptedSerialization(_) => {} + other => panic!("expected CorruptedSerialization, got: {:?}", other), + }, + Err(other) => panic!("expected ProtocolError, got: {:?}", other), + Ok(_) => panic!("expected error, got Ok"), + } + } + + #[test] + fn expiration_ranges_round_trip() { + let ranges = vec![(1u64, 5u64), (6, 10), (100, 200)]; + let original = NullifierExpirationRanges::new(ranges.clone()); + let bytes = original.encode().expect("encode"); + let decoded = NullifierExpirationRanges::decode(&bytes).expect("decode"); + assert_eq!(decoded.into_inner(), ranges); + } + + #[test] + fn expiration_ranges_decode_garbage_returns_corrupted_serialization() { + match NullifierExpirationRanges::decode(&[0xAB, 0xCD]) { + Err(Error::Protocol(b)) => match b.as_ref() { + ProtocolError::CorruptedSerialization(_) => {} + other => panic!("expected CorruptedSerialization, got: {:?}", other), + }, + Err(other) => panic!("expected ProtocolError, got: {:?}", other), + Ok(_) => panic!("expected error, got Ok"), + } + } + + #[test] + fn parse_key_rejects_wrong_length() { + // < 16 bytes + let err = CompactedNullifierChange::parse_key(&[0u8; 8]) + .expect_err("short key should be rejected"); + assert!(matches!(err, Error::Protocol(_))); + + // > 16 bytes + let err = CompactedNullifierChange::parse_key(&[0u8; 24]) + .expect_err("long key should be rejected"); + assert!(matches!(err, Error::Protocol(_))); + + // Empty + let err = + CompactedNullifierChange::parse_key(&[]).expect_err("empty key should be rejected"); + assert!(matches!(err, Error::Protocol(_))); + } + + #[test] + fn parse_key_decodes_big_endian_u64_pair() { + let mut key = [0u8; 16]; + key[0..8].copy_from_slice(&100u64.to_be_bytes()); + key[8..16].copy_from_slice(&200u64.to_be_bytes()); + let (start, end) = CompactedNullifierChange::parse_key(&key).expect("parse"); + assert_eq!(start, 100); + assert_eq!(end, 200); + + // Max boundary values. + let mut key = [0u8; 16]; + key[0..8].copy_from_slice(&u64::MAX.to_be_bytes()); + key[8..16].copy_from_slice(&u64::MAX.to_be_bytes()); + let (start, end) = CompactedNullifierChange::parse_key(&key).expect("parse u64::MAX"); + assert_eq!(start, u64::MAX); + assert_eq!(end, u64::MAX); + } +} + /// A single nullifier change entry for a specific block height. pub struct NullifierChangePerBlock { /// The block height this entry belongs to. diff --git a/packages/rs-drive/src/drive/shielded/paths.rs b/packages/rs-drive/src/drive/shielded/paths.rs index 35d1142e185..98b122d1ea2 100644 --- a/packages/rs-drive/src/drive/shielded/paths.rs +++ b/packages/rs-drive/src/drive/shielded/paths.rs @@ -140,3 +140,68 @@ pub fn nullifiers_path_for_pool( )))), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::drive::DriveError; + use crate::error::Error; + + #[test] + fn pool_type_0_returns_credit_pool_nullifiers_path() { + // pool_type = 0 maps to the main credit shielded pool. + let path = nullifiers_path_for_pool(0, None).expect("credit pool path"); + assert_eq!(path, shielded_credit_pool_nullifiers_path_vec()); + // pool_identifier being Some(_) is ignored for pool_type 0. + let path2 = nullifiers_path_for_pool(0, Some(&[0xABu8; 32])).expect("credit pool path"); + assert_eq!(path2, shielded_credit_pool_nullifiers_path_vec()); + } + + #[test] + fn pool_type_1_and_2_return_not_supported() { + // Pool types 1 and 2 hit the NotSupported error branch. + let err1 = + nullifiers_path_for_pool(1, None).expect_err("pool type 1 should return NotSupported"); + assert!(matches!(err1, Error::Drive(DriveError::NotSupported(_)))); + + let err2 = nullifiers_path_for_pool(2, Some(&[0xFFu8; 32])) + .expect_err("pool type 2 should return NotSupported"); + assert!(matches!(err2, Error::Drive(DriveError::NotSupported(_)))); + } + + #[test] + fn unknown_pool_type_returns_invalid_input() { + let err = nullifiers_path_for_pool(999, None) + .expect_err("unknown pool type should return InvalidInput"); + match err { + Error::Drive(DriveError::InvalidInput(msg)) => assert!(msg.contains("999")), + other => panic!("expected InvalidInput, got: {:?}", other), + } + // Also exercise edge values (u32::MAX). + let err_max = + nullifiers_path_for_pool(u32::MAX, None).expect_err("u32::MAX should be invalid input"); + assert!(matches!(err_max, Error::Drive(DriveError::InvalidInput(_)))); + } + + #[test] + fn shielded_pool_path_vec_matches_static_path() { + // Cross-check: the vec and static-slice versions encode the same path bytes. + let arr = shielded_credit_pool_path(); + let v = shielded_credit_pool_path_vec(); + assert_eq!(arr.len(), v.len()); + for (a, b) in arr.iter().zip(v.iter()) { + assert_eq!(*a, b.as_slice()); + } + } + + #[test] + fn anchors_paths_by_height_vs_pool_tree_use_distinct_keys() { + // Regression guard: SHIELDED_ANCHORS_IN_POOL_KEY != SHIELDED_ANCHORS_BY_HEIGHT_KEY. + // A bug confusing these keys would silently break pruning. + let pool_path = shielded_credit_pool_anchors_path_vec(); + let by_height = shielded_credit_pool_anchors_by_height_path_vec(); + assert_eq!(pool_path.len(), 3); + assert_eq!(by_height.len(), 3); + assert_ne!(pool_path[2], by_height[2]); + } +} diff --git a/packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs b/packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs index 706d6b1beeb..8581c424819 100644 --- a/packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs @@ -84,3 +84,158 @@ impl Drive { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::drive::shielded::paths::{ + shielded_credit_pool_anchors_by_height_path, shielded_credit_pool_anchors_path, + }; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + use grovedb::Element; + + /// Inserts (anchor_bytes -> height) and (height_be -> anchor_bytes) at a given height. + fn seed_anchor( + drive: &crate::drive::Drive, + transaction: &grovedb::Transaction, + anchor: [u8; 32], + height: u64, + platform_version: &PlatformVersion, + ) { + let grove_version = &platform_version.drive.grove_version; + + drive + .grove + .insert( + &shielded_credit_pool_anchors_path(), + &anchor, + Element::new_item(height.to_be_bytes().to_vec()), + None, + Some(transaction), + grove_version, + ) + .unwrap() + .expect("insert anchor"); + + drive + .grove + .insert( + &shielded_credit_pool_anchors_by_height_path(), + &height.to_be_bytes(), + Element::new_item(anchor.to_vec()), + None, + Some(transaction), + grove_version, + ) + .unwrap() + .expect("insert by height"); + } + + #[test] + fn prune_on_empty_tree_is_ok_noop() { + // Empty anchors-by-height tree -> empty entries -> early return Ok(()). + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + drive + .prune_shielded_pool_anchors_v0(100, &transaction, platform_version) + .expect("pruning an empty tree should be a noop"); + } + + #[test] + fn prune_cutoff_excludes_anchors_at_cutoff_height() { + // Cutoff is exclusive (`RangeTo ..cutoff`). An anchor at exactly `cutoff` + // must not be pruned. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + seed_anchor(&drive, &transaction, [1u8; 32], 10, platform_version); + seed_anchor(&drive, &transaction, [2u8; 32], 20, platform_version); + + drive + .prune_shielded_pool_anchors_v0(20, &transaction, platform_version) + .expect("prune below 20"); + + // Anchor at height 10 should be gone; anchor at height 20 should remain. + let mut drive_ops = vec![]; + assert!(!drive + .has_shielded_anchor( + &[1u8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + assert!(drive + .has_shielded_anchor( + &[2u8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } + + #[test] + fn prune_removes_all_below_cutoff() { + // Multiple old anchors all below cutoff -> all pruned. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + for h in 1u64..=5 { + seed_anchor(&drive, &transaction, [h as u8; 32], h, platform_version); + } + + drive + .prune_shielded_pool_anchors_v0(10, &transaction, platform_version) + .expect("prune below 10"); + + let mut drive_ops = vec![]; + for h in 1u64..=5 { + assert!(!drive + .has_shielded_anchor( + &[h as u8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } + } + + #[test] + fn prune_preserves_all_at_or_above_cutoff() { + // Cutoff below all entries -> nothing pruned. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + seed_anchor(&drive, &transaction, [1u8; 32], 100, platform_version); + seed_anchor(&drive, &transaction, [2u8; 32], 200, platform_version); + + drive + .prune_shielded_pool_anchors_v0(50, &transaction, platform_version) + .expect("prune below 50"); + + let mut drive_ops = vec![]; + assert!(drive + .has_shielded_anchor( + &[1u8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + assert!(drive + .has_shielded_anchor( + &[2u8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } +} diff --git a/packages/rs-drive/src/drive/shielded/read_total_balance/v0/mod.rs b/packages/rs-drive/src/drive/shielded/read_total_balance/v0/mod.rs index 04159ac48f2..dcde1d80c76 100644 --- a/packages/rs-drive/src/drive/shielded/read_total_balance/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/read_total_balance/v0/mod.rs @@ -31,3 +31,51 @@ impl Drive { .unwrap_or(0)) } } + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + + #[test] + fn read_total_balance_returns_zero_on_fresh_pool() { + // Fresh initial state inserts SumItem(0) at the total balance key, so + // read must return 0 (exercises Some(0) path, not unwrap_or). + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let mut drive_ops = vec![]; + + let bal = drive + .read_shielded_pool_total_balance_v0(None, &mut drive_ops, platform_version) + .expect("read fresh total"); + assert_eq!(bal, 0); + } + + #[test] + fn update_then_read_total_balance_roundtrips() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Apply an update op moving the total balance to a specific value. + let ops = + Drive::update_total_balance_op(123_456_789, platform_version).expect("build update op"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("apply update op"); + + let mut drive_ops = vec![]; + let bal = drive + .read_shielded_pool_total_balance_v0(None, &mut drive_ops, platform_version) + .expect("read updated total"); + assert_eq!(bal, 123_456_789); + } +} diff --git a/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs b/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs index 8c717cdc493..23854dbccb1 100644 --- a/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs @@ -121,3 +121,141 @@ impl Drive { Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::drive::shielded::paths::{ + shielded_credit_pool_path, SHIELDED_MOST_RECENT_ANCHOR_KEY, + }; + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + use grovedb::Element; + + #[test] + fn record_on_empty_pool_does_nothing() { + // The commitment tree is empty - current_anchor_bytes is [0; 32]. The + // should_store guard (`!= [0u8; 32]`) keeps us out of the write branch, + // so has_shielded_anchor on any anchor still returns false. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + drive + .record_shielded_pool_anchor_if_changed_v0(10, &transaction, platform_version) + .expect("record on empty tree should succeed"); + + let mut drive_ops = vec![]; + assert!(!drive + .has_shielded_anchor( + &[0u8; 32], + Some(&transaction), + &mut drive_ops, + platform_version + ) + .unwrap()); + } + + #[test] + fn record_after_note_insert_stores_anchor() { + // Insert a real note - the CommitmentTree advances and the current anchor + // becomes non-zero. record_anchor_if_changed should then store an entry. + // Note: cmx bytes must encode a valid Pallas field element; small values + // like [0x01; 32] work because they're below the field modulus. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + + let ops = Drive::insert_note_op( + [0xAAu8; 32], + [0x01u8; 32], + vec![0x42; 216], + platform_version, + ) + .expect("build insert note op"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("apply note op"); + + // Record the anchor. + drive + .record_shielded_pool_anchor_if_changed_v0(5, &transaction, platform_version) + .expect("record anchor after insert"); + + // The most recent anchor slot was updated to a non-zero value. + let elem = drive + .grove + .get( + &shielded_credit_pool_path(), + &[SHIELDED_MOST_RECENT_ANCHOR_KEY], + Some(&transaction), + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("most recent anchor"); + if let Element::Item(bytes, _) = elem { + assert_ne!(bytes, vec![0u8; 32], "most recent anchor should be updated"); + } else { + panic!("expected Element::Item for most recent anchor"); + } + } + + #[test] + fn corrupted_most_recent_anchor_returns_corrupted_element_type() { + // Overwrite the most recent anchor key with an invalid length item (e.g. 10 bytes). + // The try_into into [u8; 32] must fail with CorruptedElementType. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let transaction = drive.grove.start_transaction(); + let grove_version = &platform_version.drive.grove_version; + + // First: insert a note so current_anchor is non-zero (otherwise we'd skip past + // the failing read via should_store=false). cmx must be a valid Pallas element. + let ops = Drive::insert_note_op([1u8; 32], [0x02u8; 32], vec![3u8; 216], platform_version) + .expect("build"); + let grove_ops = + crate::fees::op::LowLevelDriveOperation::grovedb_operations_batch_consume(ops); + drive + .grove_apply_batch_with_add_costs( + grove_ops, + false, + Some(&transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("apply"); + + // Corrupt the most recent anchor item to a wrong length. + drive + .grove + .insert( + &shielded_credit_pool_path(), + &[SHIELDED_MOST_RECENT_ANCHOR_KEY], + Element::new_item(vec![0xEEu8; 10]), + None, + Some(&transaction), + grove_version, + ) + .unwrap() + .expect("corrupt most recent anchor"); + + let err = drive + .record_shielded_pool_anchor_if_changed_v0(1, &transaction, platform_version) + .expect_err("expected CorruptedElementType"); + assert!( + matches!(err, Error::Drive(DriveError::CorruptedElementType(_))), + "got: {:?}", + err + ); + } +} diff --git a/packages/rs-drive/src/drive/shielded/update_total_balance/v0/mod.rs b/packages/rs-drive/src/drive/shielded/update_total_balance/v0/mod.rs index ae55907268e..b7fcc8814ca 100644 --- a/packages/rs-drive/src/drive/shielded/update_total_balance/v0/mod.rs +++ b/packages/rs-drive/src/drive/shielded/update_total_balance/v0/mod.rs @@ -30,3 +30,44 @@ impl Drive { )]) } } + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + + #[test] + fn balance_at_i64_max_converts_successfully() { + // i64::MAX as u64 converts without overflow. + let ops = + Drive::update_total_balance_op_v0(i64::MAX as u64).expect("i64::MAX should be valid"); + assert_eq!(ops.len(), 1); + } + + #[test] + fn balance_exceeding_i64_max_returns_corrupted_drive_state() { + // Values > i64::MAX trip the try_from guard and produce CorruptedDriveState. + let err = Drive::update_total_balance_op_v0(u64::MAX) + .expect_err("u64::MAX > i64::MAX should fail"); + match err { + Error::Drive(DriveError::CorruptedDriveState(msg)) => { + assert!(msg.contains("exceeds i64::MAX")); + } + other => panic!("expected CorruptedDriveState, got: {:?}", other), + } + + let err2 = Drive::update_total_balance_op_v0(i64::MAX as u64 + 1) + .expect_err("i64::MAX+1 should fail"); + assert!(matches!( + err2, + Error::Drive(DriveError::CorruptedDriveState(_)) + )); + } + + #[test] + fn balance_zero_produces_single_op() { + let ops = Drive::update_total_balance_op_v0(0).expect("zero balance"); + assert_eq!(ops.len(), 1); + } +} diff --git a/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs b/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs index 28e841df181..af6665127cd 100644 --- a/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs +++ b/packages/rs-drive/src/verify/state_transition/state_transition_execution_path_queries/token_transition.rs @@ -365,4 +365,206 @@ mod tests { assert_eq!(path_query.path, expected_query.path); assert_eq!(path_query.query.limit, expected_query.query.limit); } + + // ----------------------------------------------------------------------- + // Additional coverage for error / alternate branches. + // + // The happy paths (balance / info / price queries for transitions whose + // history is disabled) are covered above. The tests below: + // * exercise the `keeps_history` → historical-document query path + // * exercise transitions that *always* use the historical-document + // query regardless of keeps_* flags (DestroyFrozenFunds, + // EmergencyAction, ConfigUpdate, Claim) + // * exercise the error arm where the token position on the contract is + // missing (expected_token_configuration returns Err). + // ----------------------------------------------------------------------- + + /// Helper to create a contract where every keeps_*_history flag is true. + fn create_test_contract_with_history() -> (DataContract, Identifier, Identifier) { + let mut token_config = TokenConfigurationV0::default_most_restrictive(); + *token_config.keeps_history_mut() = TokenKeepsHistoryRules::V0(TokenKeepsHistoryRulesV0 { + keeps_transfer_history: true, + keeps_freezing_history: true, + keeps_minting_history: true, + keeps_burning_history: true, + keeps_direct_pricing_history: true, + keeps_direct_purchase_history: true, + }); + + let contract = DataContract::V1(DataContractV1 { + id: Identifier::from([2u8; 32]), + version: 0, + owner_id: Default::default(), + document_types: Default::default(), + config: DataContractConfig::V0(DataContractConfigV0 { + can_be_deleted: false, + readonly: false, + keeps_history: false, + documents_keep_history_contract_default: false, + documents_mutable_contract_default: false, + documents_can_be_deleted_contract_default: false, + requires_identity_encryption_bounded_key: None, + requires_identity_decryption_bounded_key: None, + }), + schema_defs: None, + created_at: None, + updated_at: None, + created_at_block_height: None, + updated_at_block_height: None, + created_at_epoch: None, + updated_at_epoch: None, + groups: Default::default(), + tokens: BTreeMap::from([(0, TokenConfiguration::V0(token_config))]), + keywords: Vec::new(), + description: None, + }); + + let token_id = contract.token_id(0).expect("expected token at position 0"); + let owner_id = Identifier::from([6u8; 32]); + (contract, token_id, owner_id) + } + + /// Assert that the given path query targets the TokenHistory system + /// contract by matching the contract id as an exact path segment (not a + /// flattened byte substring, which could spuriously match across segment + /// boundaries). + fn assert_path_targets_token_history( + path_query: &PathQuery, + platform_version: &PlatformVersion, + ) { + let token_history_contract = dpp::system_data_contracts::load_system_data_contract( + dpp::system_data_contracts::SystemDataContract::TokenHistory, + platform_version, + ) + .expect("expected TokenHistory contract"); + let contract_id_bytes = token_history_contract.id().into_buffer(); + assert!( + path_query + .path + .iter() + .any(|segment| segment.as_slice() == contract_id_bytes.as_slice()), + "expected path_query.path to contain the TokenHistory contract id \ + ({:?}) as an exact segment, got path: {:?}", + contract_id_bytes, + path_query.path + ); + } + + // --- Burn with history enabled → historical document query path. + #[test] + fn burn_transition_with_history_produces_historical_document_query() { + let platform_version = PlatformVersion::latest(); + let (contract, token_id, owner_id) = create_test_contract_with_history(); + + let transition = TokenTransition::Burn(TokenBurnTransition::V0(TokenBurnTransitionV0 { + base: make_base(contract.id(), token_id), + burn_amount: 100, + public_note: None, + })); + + let path_query = transition + .try_transition_into_path_query_with_contract(&contract, owner_id, platform_version) + .expect("expected historical-document path query"); + + assert_path_targets_token_history(&path_query, platform_version); + } + + // --- Freeze with history → historical document query path. + #[test] + fn freeze_transition_with_history_produces_historical_document_query() { + let platform_version = PlatformVersion::latest(); + let (contract, token_id, owner_id) = create_test_contract_with_history(); + let frozen_identity_id = Identifier::from([12u8; 32]); + + let transition = + TokenTransition::Freeze(TokenFreezeTransition::V0(TokenFreezeTransitionV0 { + base: make_base(contract.id(), token_id), + identity_to_freeze_id: frozen_identity_id, + public_note: None, + })); + + let path_query = transition + .try_transition_into_path_query_with_contract(&contract, owner_id, platform_version) + .expect("expected freeze with history to produce historical query"); + + assert_path_targets_token_history(&path_query, platform_version); + } + + // --- Unknown token position → expected_token_configuration returns Err. + #[test] + fn transition_with_invalid_token_position_returns_error() { + let platform_version = PlatformVersion::latest(); + let (contract, token_id, owner_id) = create_test_contract_and_ids(); + + // Use a token_contract_position that doesn't exist on the contract + // (0 exists, but 99 does not). base.token_contract_position = 99 + // should cause expected_token_configuration to err out. + let bad_base = TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 1, + token_contract_position: 99, + data_contract_id: contract.id(), + token_id, + using_group_info: None, + }); + + let transition = TokenTransition::Burn(TokenBurnTransition::V0(TokenBurnTransitionV0 { + base: bad_base, + burn_amount: 1, + public_note: None, + })); + + let result = transition.try_transition_into_path_query_with_contract( + &contract, + owner_id, + platform_version, + ); + assert!( + result.is_err(), + "expected error for invalid token position, got Ok" + ); + } + + // --- Unfreeze with history → historical document query path. + #[test] + fn unfreeze_transition_with_history_produces_historical_document_query() { + let platform_version = PlatformVersion::latest(); + let (contract, token_id, owner_id) = create_test_contract_with_history(); + let frozen_identity_id = Identifier::from([13u8; 32]); + + let transition = + TokenTransition::Unfreeze(TokenUnfreezeTransition::V0(TokenUnfreezeTransitionV0 { + base: make_base(contract.id(), token_id), + frozen_identity_id, + public_note: None, + })); + + let path_query = transition + .try_transition_into_path_query_with_contract(&contract, owner_id, platform_version) + .expect("expected unfreeze with history to produce historical query"); + + assert_path_targets_token_history(&path_query, platform_version); + } + + // --- SetPriceForDirectPurchase with history → historical document query. + #[test] + fn set_price_with_history_produces_historical_document_query() { + let platform_version = PlatformVersion::latest(); + let (contract, token_id, owner_id) = create_test_contract_with_history(); + + let transition = TokenTransition::SetPriceForDirectPurchase( + TokenSetPriceForDirectPurchaseTransition::V0( + TokenSetPriceForDirectPurchaseTransitionV0 { + base: make_base(contract.id(), token_id), + price: None, + public_note: None, + }, + ), + ); + + let path_query = transition + .try_transition_into_path_query_with_contract(&contract, owner_id, platform_version) + .expect("expected set-price with history to produce historical query"); + + assert_path_targets_token_history(&path_query, platform_version); + } } diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index dc0d3a58faa..b4d15d23b6f 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -2510,4 +2510,333 @@ mod tests { result ); } + + // ----------------------------------------------------------------------- + // Additional coverage for rarely-executed error branches. + // + // The following tests exercise variants that were not previously + // covered by the inline suite, and in particular focus on error arms + // (unknown-contract, invalid-transition, malformed-input) that are + // otherwise hard to exercise through happy-path tests. + // ----------------------------------------------------------------------- + + // --- DataContractUpdate: empty proof returns Error::Proof or Error::GroveDB. + #[test] + fn verify_data_contract_update_empty_proof_returns_error() { + let (_drive, contract) = setup_drive_and_contract(); + let platform_version = PlatformVersion::latest(); + let data_contract_serialized: DataContractInSerializationFormat = contract + .clone() + .try_into_platform_versioned(platform_version) + .expect("expected to serialize contract"); + + use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; + use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransitionV0; + let st = StateTransition::DataContractUpdate(DataContractUpdateTransition::V0( + DataContractUpdateTransitionV0 { + identity_contract_nonce: 1, + data_contract: data_contract_serialized, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + }, + )); + + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + // Empty proof → must error (either Error::Proof or Error::GroveDB). + assert!( + matches!( + result, + Err(crate::error::Error::Proof(_)) | Err(crate::error::Error::GroveDB(_)) + ), + "expected Error::Proof or Error::GroveDB for empty proof, got: {:?}", + result + ); + } + + // --- MasternodeVote: unknown contract returns a specific + // `ProofError::UnknownContract` because the provider returns None. + #[test] + fn verify_masternode_vote_unknown_contract_returns_error() { + let platform_version = PlatformVersion::latest(); + use dpp::state_transition::masternode_vote_transition::v0::MasternodeVoteTransitionV0; + use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; + use dpp::voting::votes::resource_vote::ResourceVote; + use dpp::voting::votes::Vote; + + let st = StateTransition::MasternodeVote(MasternodeVoteTransition::V0( + MasternodeVoteTransitionV0 { + pro_tx_hash: dpp::prelude::Identifier::from([1u8; 32]), + voter_identity_id: dpp::prelude::Identifier::from([2u8; 32]), + vote: Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll { + contract_id: dpp::prelude::Identifier::from([7u8; 32]), + document_type_name: "some_type".to_string(), + index_name: "idx".to_string(), + index_values: vec![], + }, + ), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + nonce: 1, + signature_public_key_id: 0, + signature: Default::default(), + }, + )); + + // Provider always returns None – triggers UnknownContract. + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + let err = result.expect_err("expected UnknownContract error, got Ok"); + match err { + crate::error::Error::Proof(ProofError::UnknownContract(msg)) => { + assert!( + msg.contains("unknown contract") && msg.contains("resource vote"), + "unexpected UnknownContract message: {msg}" + ); + } + other => panic!("expected Error::Proof(UnknownContract), got {:?}", other), + } + } + + // --- ShieldedWithdrawal: empty proof + no nullifiers → error. + // + // The `first_nullifier` guard in the verifier is only reachable once + // `verify_shielded_nullifiers` returns Ok. With an empty proof, grove-db + // proof verification errors out first, so this test cannot reach the + // `InvalidTransition` guard specifically — we only assert that some + // Error::Proof / Error::GroveDB is returned on the failing path. + #[test] + fn verify_shielded_withdrawal_empty_proof_error() { + let platform_version = PlatformVersion::latest(); + use dpp::identity::core_script::CoreScript; + use dpp::state_transition::shielded_withdrawal_transition::v0::ShieldedWithdrawalTransitionV0; + use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; + use dpp::withdrawal::Pooling; + + let st = StateTransition::ShieldedWithdrawal(ShieldedWithdrawalTransition::V0( + ShieldedWithdrawalTransitionV0 { + actions: vec![], + unshielding_amount: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: CoreScript::from_bytes(vec![]), + }, + )); + + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + assert!( + matches!( + result, + Err(crate::error::Error::Proof(_)) | Err(crate::error::Error::GroveDB(_)) + ), + "expected error for shielded withdrawal with empty proof, got: {:?}", + result + ); + } + + // --- ShieldedTransfer: empty proof leads to an error (Error::Proof + // or Error::GroveDB) through the verify_shielded_nullifiers path. + #[test] + fn verify_shielded_transfer_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + + let st = StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( + ShieldedTransferTransitionV0 { + actions: vec![], + value_balance: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + }, + )); + + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + assert!( + matches!( + result, + Err(crate::error::Error::Proof(_)) | Err(crate::error::Error::GroveDB(_)) + ), + "expected error for shielded transfer with empty proof, got: {:?}", + result + ); + } + + // --- Unshield: empty proof leads to an error. + #[test] + fn verify_unshield_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + use dpp::state_transition::unshield_transition::v0::UnshieldTransitionV0; + use dpp::state_transition::unshield_transition::UnshieldTransition; + + let st = StateTransition::Unshield(UnshieldTransition::V0(UnshieldTransitionV0 { + output_address: Default::default(), + actions: vec![], + unshielding_amount: 0, + anchor: [0u8; 32], + proof: vec![], + binding_signature: [0u8; 64], + })); + + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + assert!( + matches!( + result, + Err(crate::error::Error::Proof(_)) | Err(crate::error::Error::GroveDB(_)) + ), + "expected error for unshield with empty proof, got: {:?}", + result + ); + } + + // --- IdentityCreditTransferToAddresses: empty proof returns error. + #[test] + fn verify_identity_credit_transfer_to_addresses_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + use dpp::state_transition::identity_credit_transfer_to_addresses_transition::v0::IdentityCreditTransferToAddressesTransitionV0; + use dpp::state_transition::identity_credit_transfer_to_addresses_transition::IdentityCreditTransferToAddressesTransition; + + let st = StateTransition::IdentityCreditTransferToAddresses( + IdentityCreditTransferToAddressesTransition::V0( + IdentityCreditTransferToAddressesTransitionV0 { + identity_id: dpp::prelude::Identifier::from([3u8; 32]), + ..Default::default() + }, + ), + ); + + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + assert!( + matches!( + result, + Err(crate::error::Error::Proof(_)) | Err(crate::error::Error::GroveDB(_)) + ), + "expected error for identity credit transfer to addresses with empty proof, got: {:?}", + result + ); + } + + // --- Batch with a single Token transition + unknown contract returns + // UnknownContract error (covers the token transition branch). + #[test] + fn verify_batch_token_transition_unknown_contract_returns_error() { + let platform_version = PlatformVersion::latest(); + + use dpp::state_transition::batch_transition::batched_transition::token_burn_transition::v0::TokenBurnTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::token_burn_transition::TokenBurnTransition; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; + use dpp::state_transition::batch_transition::token_base_transition::v0::TokenBaseTransitionV0; + use dpp::state_transition::batch_transition::token_base_transition::TokenBaseTransition; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::batch_transition::BatchTransitionV1; + + let token_base = TokenBaseTransition::V0(TokenBaseTransitionV0 { + identity_contract_nonce: 1, + token_contract_position: 0, + data_contract_id: dpp::prelude::Identifier::from([55u8; 32]), + token_id: dpp::prelude::Identifier::from([66u8; 32]), + using_group_info: None, + }); + let token_transition = + TokenTransition::Burn(TokenBurnTransition::V0(TokenBurnTransitionV0 { + base: token_base, + burn_amount: 42, + public_note: None, + })); + + // V1 supports BatchedTransition::Token. V0 only supports document + // transitions — use V1 to include a token transition. + let st = StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + owner_id: dpp::prelude::Identifier::from([9u8; 32]), + transitions: vec![BatchedTransition::Token(token_transition)], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + })); + + // Provider returns None — triggers UnknownContract for the token. + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &[], + known_contracts_provider_fn, + platform_version, + ); + + let err = result.expect_err("expected UnknownContract error, got Ok"); + match err { + crate::error::Error::Proof(ProofError::UnknownContract(msg)) => { + assert!( + msg.contains("unknown contract") && msg.contains("token verification"), + "unexpected UnknownContract message: {msg}" + ); + } + other => panic!("expected Error::Proof(UnknownContract), got {:?}", other), + } + } }