diff --git a/Cargo.lock b/Cargo.lock index 1636f42f850..73d5ce6ed05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2091,6 +2091,7 @@ dependencies = [ "drive-proof-verifier", "envy", "file-rotate", + "grovedb-commitment-tree", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2100,6 +2101,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "mockall", + "nonempty", "platform-version", "prost 0.14.3", "rand 0.8.5", @@ -2799,6 +2801,7 @@ dependencies = [ "grovedb-storage", "incrementalmerkletree", "orchard", + "shardtree", "thiserror 2.0.18", ] @@ -6526,6 +6529,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shardtree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "359e552886ae54d1642091645980d83f7db465fd9b5b0248e3680713c1773388" +dependencies = [ + "bitflags 2.11.0", + "either", + "incrementalmerkletree", + "tracing", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs index ff1b4805936..931c4783fe0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v0/state_transition_validation.rs @@ -77,6 +77,18 @@ impl StateTransitionStructureValidation for ShieldTransitionV0 { ); } + // amount must fit in i64 (Orchard protocol uses i64 internally for value_balance) + if self.amount > i64::MAX as u64 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount exceeds maximum allowed value".to_string(), + ), + ) + .into(), + ); + } + // Proof must not be empty let result = validate_proof_not_empty(&self.proof); if !result.is_valid() { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shielded_transfer_transition/v0/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shielded_transfer_transition/v0/state_transition_validation.rs index 7329f6c8706..091cead328c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shielded_transfer_transition/v0/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shielded_transfer_transition/v0/state_transition_validation.rs @@ -24,6 +24,18 @@ impl StateTransitionStructureValidation for ShieldedTransferTransitionV0 { return result; } + // value_balance must be positive (it IS the fee for shielded transfers) + if self.value_balance == 0 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shielded transfer value_balance must be greater than zero".to_string(), + ), + ) + .into(), + ); + } + // value_balance must fit in i64 (required for Orchard protocol) if self.value_balance > i64::MAX as u64 { return SimpleConsensusValidationResult::new_with_error( diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index d3d46f933ae..4a1f7151435 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -82,6 +82,8 @@ derive_more = { version = "1.0", features = ["from", "deref", "deref_mut"] } async-trait = "0.1.77" console-subscriber = { version = "0.4", optional = true } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f", optional = true } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dd99ed1db0350e5f39127573808dd172c6bc2346" } +nonempty = "0.11" [dev-dependencies] platform-version = { path = "../rs-platform-version", features = [ @@ -101,6 +103,7 @@ dpp = { path = "../rs-dpp", default-features = false, features = [ drive = { path = "../rs-drive", features = ["fixtures-and-mocks"] } drive-proof-verifier = { path = "../rs-drive-proof-verifier" } strategy-tests = { path = "../strategy-tests" } +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "dd99ed1db0350e5f39127573808dd172c6bc2346", features = ["client"] } assert_matches = "1.5.0" drive-abci = { path = ".", features = ["testing-config", "mocks"] } bls-signatures = { git = "https://github.com/dashpay/bls-signatures", rev = "0842b17583888e8f46c252a4ee84cdfd58e0546f" } @@ -120,6 +123,8 @@ testing-config = [] grovedbg = ["drive/grovedbg"] # `abci-server replay` command replay = ["dep:time", "tenderdash-abci/serde"] +# Long-running shielded strategy tests (ZK proof generation) +__shielded_strategy_tests = [] [[bin]] name = "drive-abci" path = "src/main.rs" @@ -128,4 +133,5 @@ path = "src/main.rs" unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(tokio_unstable)', 'cfg(create_sdk_test_data)', + 'cfg(feature, values("__shielded_strategy_tests"))', ] } diff --git a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs index e6042456b00..f61f908af9f 100644 --- a/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs @@ -344,6 +344,21 @@ where platform_version, )?; + // Clean up expired compacted nullifier entries + self.cleanup_recent_block_storage_nullifiers(&block_info, transaction, platform_version)?; + + // Record shielded pool anchor if the commitment tree changed this block. + // This stores block_height → anchor_bytes so shielded transactions can + // reference a recent anchor for spend authorization. + self.record_shielded_pool_anchor_if_changed( + block_proposal.height, + transaction, + platform_version, + )?; + + // Prune anchors older than the configured retention depth + self.prune_shielded_pool_anchors(block_proposal.height, transaction, platform_version)?; + // Pool withdrawals into transactions queue // Takes queued withdrawals, creates untiled withdrawal transaction payload, saves them to queue diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/mod.rs index 03c783e1dd9..a0a96325351 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/mod.rs @@ -1,5 +1,7 @@ mod add_process_epoch_change_operations; pub mod process_block_fees_and_validate_sum_trees; +mod prune_shielded_pool_anchors; +mod record_shielded_pool_anchor; #[cfg(test)] mod tests; diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/prune_shielded_pool_anchors/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/prune_shielded_pool_anchors/mod.rs new file mode 100644 index 00000000000..e12062888cb --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/prune_shielded_pool_anchors/mod.rs @@ -0,0 +1,38 @@ +mod v0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Prunes shielded pool anchors older than the configured retention depth. + pub(in crate::execution) fn prune_shielded_pool_anchors( + &self, + block_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .block_end + .prune_shielded_pool_anchors + { + None => Ok(()), + Some(0) => { + self.prune_shielded_pool_anchors_v0(block_height, transaction, platform_version) + } + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "prune_shielded_pool_anchors".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/prune_shielded_pool_anchors/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/prune_shielded_pool_anchors/v0/mod.rs new file mode 100644 index 00000000000..f6dfcdc6d63 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/prune_shielded_pool_anchors/v0/mod.rs @@ -0,0 +1,44 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Prunes anchors older than `shielded_anchor_retention_blocks` from the current height. + /// + /// Checks interval and retention depth conditions, then delegates to + /// `Drive::prune_shielded_pool_anchors` for the actual GroveDB operations. + pub(super) fn prune_shielded_pool_anchors_v0( + &self, + block_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let event_constants = &platform_version + .drive_abci + .validation_and_processing + .event_constants; + let retention_blocks = event_constants.shielded_anchor_retention_blocks; + let pruning_interval = event_constants.shielded_anchor_pruning_interval; + + // Only prune every N blocks to avoid unnecessary work + if !block_height.is_multiple_of(pruning_interval) { + return Ok(()); + } + + // Nothing to prune if we haven't reached the retention depth yet + if block_height <= retention_blocks { + return Ok(()); + } + + let cutoff_height = block_height - retention_blocks; + + self.drive + .prune_shielded_pool_anchors(cutoff_height, transaction, platform_version) + .map_err(Error::Drive) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/record_shielded_pool_anchor/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/record_shielded_pool_anchor/mod.rs new file mode 100644 index 00000000000..2c6db9f651c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/record_shielded_pool_anchor/mod.rs @@ -0,0 +1,40 @@ +mod v0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Records the current shielded pool anchor if the commitment tree changed this block. + pub(in crate::execution) fn record_shielded_pool_anchor_if_changed( + &self, + block_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .block_end + .record_shielded_pool_anchor + { + None => Ok(()), + Some(0) => self.record_shielded_pool_anchor_if_changed_v0( + block_height, + transaction, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "record_shielded_pool_anchor_if_changed".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/record_shielded_pool_anchor/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/record_shielded_pool_anchor/v0/mod.rs new file mode 100644 index 00000000000..b5c800d2f72 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/record_shielded_pool_anchor/v0/mod.rs @@ -0,0 +1,27 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Records the current shielded pool anchor if the commitment tree changed this block. + /// + /// Delegates to `Drive::record_shielded_pool_anchor_if_changed` which handles + /// all GroveDB operations: reading the current and most recent anchors, and + /// conditionally writing to the anchors tree, anchors-by-height tree, and + /// most recent anchor item. + pub(super) fn record_shielded_pool_anchor_if_changed_v0( + &self, + block_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + self.drive + .record_shielded_pool_anchor_if_changed(block_height, transaction, platform_version) + .map_err(Error::Drive) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 090cb1ce4ab..345fa4d963f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -27,6 +27,10 @@ use drive::drive::saved_block_transactions::{ ADDRESS_BALANCES_KEY_U8, COMPACTED_ADDRESSES_EXPIRATION_TIME_KEY_U8, COMPACTED_ADDRESS_BALANCES_KEY_U8, }; +use drive::drive::shielded::paths::{ + shielded_credit_pool_path, SHIELDED_ANCHORS_IN_POOL_KEY, SHIELDED_CREDIT_POOL_KEY_U8, + SHIELDED_NOTES_KEY, SHIELDED_NULLIFIERS_KEY, SHIELDED_TOTAL_BALANCE_KEY, +}; use drive::drive::system::misc_path; use drive::drive::tokens::paths::{ token_distributions_root_path, token_timed_distributions_path, tokens_root_path, @@ -110,6 +114,10 @@ impl Platform { self.transition_to_version_11(transaction, platform_version)?; } + if previous_protocol_version < 12 && platform_version.protocol_version >= 12 { + self.transition_to_version_12(transaction, platform_version)?; + } + Ok(()) } @@ -598,4 +606,68 @@ impl Platform { Ok(()) } + + /// We introduced in version 12 Shielded Pools + fn transition_to_version_12( + &self, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let addresses_path = Drive::addresses_path(); + + // Shielded credit pool SumTree under AddressBalances: [AddressBalances] / "s" + self.drive.grove_insert_if_not_exists( + addresses_path.as_slice().into(), + &[SHIELDED_CREDIT_POOL_KEY_U8], + Element::empty_sum_tree(), + Some(transaction), + None, + &platform_version.drive, + )?; + + // Notes tree (CommitmentTree = CountTree items + Sinsemilla Frontier): + // [AddressBalances, "s"] / [1] + let shielded_pool_path = shielded_credit_pool_path(); + self.drive.grove_insert_if_not_exists( + (&shielded_pool_path).into(), + &[SHIELDED_NOTES_KEY], + Element::empty_commitment_tree(11).expect("chunk_power 11 is valid"), + Some(transaction), + None, + &platform_version.drive, + )?; + + // Nullifiers tree (ProvableCountTree): [AddressBalances, "s"] / [2] + self.drive.grove_insert_if_not_exists( + (&shielded_pool_path).into(), + &[SHIELDED_NULLIFIERS_KEY], + Element::empty_provable_count_tree(), + Some(transaction), + None, + &platform_version.drive, + )?; + + // Total balance SumItem(0): [AddressBalances, "s"] / [5] + self.drive.grove_insert_if_not_exists( + (&shielded_pool_path).into(), + &[SHIELDED_TOTAL_BALANCE_KEY], + Element::new_sum_item(0), + Some(transaction), + None, + &platform_version.drive, + )?; + + // Anchors tree (NormalTree) inside pool: [AddressBalances, "s"] / [6] + // Stores block_height_be → anchor_bytes + self.drive.grove_insert_if_not_exists( + (&shielded_pool_path).into(), + &[SHIELDED_ANCHORS_IN_POOL_KEY], + Element::empty_tree(), + Some(transaction), + None, + &platform_version.drive, + )?; + + Ok(()) + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/cleanup_recent_block_storage_nullifiers/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/cleanup_recent_block_storage_nullifiers/mod.rs new file mode 100644 index 00000000000..8acb0bbb838 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/cleanup_recent_block_storage_nullifiers/mod.rs @@ -0,0 +1,41 @@ +mod v0; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Cleans up expired compacted nullifier entries from recent block storage. + pub(in crate::execution) fn cleanup_recent_block_storage_nullifiers( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .state_transition_processing + .cleanup_recent_block_storage_nullifiers + { + None => Ok(()), + Some(0) => self.cleanup_recent_block_storage_nullifiers_v0( + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "cleanup_recent_block_storage_nullifiers".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/cleanup_recent_block_storage_nullifiers/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/cleanup_recent_block_storage_nullifiers/v0/mod.rs new file mode 100644 index 00000000000..aaadf5adefd --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/cleanup_recent_block_storage_nullifiers/v0/mod.rs @@ -0,0 +1,27 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use drive::grovedb::Transaction; + +impl Platform +where + C: CoreRPCLike, +{ + /// Version 0 implementation of cleaning up expired compacted nullifier entries. + pub(super) fn cleanup_recent_block_storage_nullifiers_v0( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + self.drive.cleanup_expired_nullifier_compactions( + block_info.time_ms, + Some(transaction), + platform_version, + )?; + + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index b153dad9394..02bb9150152 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -334,7 +334,8 @@ where let maybe_fee_validation_result = match event { ExecutionEvent::PaidFromAssetLock { .. } | ExecutionEvent::Paid { .. } - | ExecutionEvent::PaidFromAddressInputs { .. } => Some(self.validate_fees_of_event( + | ExecutionEvent::PaidFromAddressInputs { .. } + | ExecutionEvent::PaidFromAssetLockToPool { .. } => Some(self.validate_fees_of_event( &event, block_info, Some(transaction), @@ -343,6 +344,7 @@ where )?), ExecutionEvent::PaidFromAssetLockWithoutIdentity { .. } | ExecutionEvent::PaidFixedCost { .. } + | ExecutionEvent::PaidFromShieldedPool { .. } | ExecutionEvent::Free { .. } => None, }; @@ -508,6 +510,61 @@ where Ok(UnpaidConsensusExecutionError(consensus_errors)) } } + ExecutionEvent::PaidFromShieldedPool { + operations, + fees_to_add_to_pool, + .. + } => { + if consensus_errors.is_empty() { + self.drive + .apply_drive_operations( + operations, + true, + block_info, + Some(transaction), + platform_version, + Some(previous_fee_versions), + ) + .map_err(Error::Drive)?; + + Ok(SuccessfulPaidExecution( + None, + FeeResult::default_with_fees(0, fees_to_add_to_pool), + )) + } else { + Ok(UnpaidConsensusExecutionError(consensus_errors)) + } + } + ExecutionEvent::PaidFromAssetLockToPool { + fees_to_add_to_pool, + operations, + .. + } => { + let fee_validation_result = maybe_fee_validation_result + .expect("fee validation result must exist for PaidFromAssetLockToPool"); + let mut all_errors = fee_validation_result.errors; + all_errors.extend(consensus_errors); + + if all_errors.is_empty() { + self.drive + .apply_drive_operations( + operations, + true, + block_info, + Some(transaction), + platform_version, + Some(previous_fee_versions), + ) + .map_err(Error::Drive)?; + + Ok(SuccessfulPaidExecution( + None, + FeeResult::default_with_fees(0, fees_to_add_to_pool), + )) + } else { + Ok(UnpaidConsensusExecutionError(all_errors)) + } + } ExecutionEvent::Free { operations } => { self.drive .apply_drive_operations( diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs index 84b91ec6afb..26ea2e160dc 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs @@ -1,4 +1,5 @@ mod cleanup_recent_block_storage_address_balances; +mod cleanup_recent_block_storage_nullifiers; mod decode_raw_state_transitions; mod execute_event; mod process_raw_state_transitions; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs index a35bba60a2e..7167c116052 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs @@ -8,6 +8,7 @@ use dpp::address_funds::fee_strategy::deduct_fee_from_inputs_and_outputs::deduct use dpp::block::block_info::BlockInfo; use dpp::consensus::state::address_funds::AddressesNotEnoughFundsError; use dpp::consensus::state::identity::IdentityInsufficientBalanceError; +use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::state_error::StateError; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::fee::fee_result::FeeResult; @@ -222,7 +223,49 @@ where )) } } + ExecutionEvent::PaidFromAssetLockToPool { + fees_to_add_to_pool, + operations, + execution_operations, + } => { + let mut estimated_fee_result = self + .drive + .apply_drive_operations( + operations.clone(), + false, + block_info, + transaction, + platform_version, + Some(previous_fee_versions), + ) + .map_err(Error::Drive)?; + + ValidationOperation::add_many_to_fee_result( + execution_operations, + &mut estimated_fee_result, + platform_version, + )?; + + let required_fee = estimated_fee_result.total_base_fee(); + if *fees_to_add_to_pool >= required_fee { + Ok(ConsensusValidationResult::new_with_data( + estimated_fee_result, + )) + } else { + Ok(ConsensusValidationResult::new_with_data_and_errors( + estimated_fee_result, + vec![StateError::InvalidShieldedProofError( + InvalidShieldedProofError::new(format!( + "shield_from_asset_lock fee insufficient: provided {} but minimum required {}", + fees_to_add_to_pool, required_fee + )), + ) + .into()], + )) + } + } ExecutionEvent::PaidFixedCost { .. } + | ExecutionEvent::PaidFromShieldedPool { .. } | ExecutionEvent::Free { .. } | ExecutionEvent::PaidFromAssetLockWithoutIdentity { .. } => Ok( ConsensusValidationResult::new_with_data(FeeResult::default()), diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index f6126ccc10e..ae579990d09 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -67,6 +67,16 @@ pub(in crate::execution) enum ExecutionEvent<'a> { /// fees to add fees_to_add_to_pool: Credits, }, + /// A drive event paid from the shielded pool's value_balance. + /// The fee is embedded in the ZK-proven value_balance and validated + /// at the processor level (validate_minimum_shielded_fee). + /// Nullifiers are stored to recent block storage as part of the drive operations. + PaidFromShieldedPool { + /// the operations that should be performed + operations: Vec>, + /// fees derived from value_balance to add to the fee pool + fees_to_add_to_pool: Credits, + }, /// A drive event that is paid from an asset lock PaidFromAssetLock { /// The identity requesting the event @@ -87,6 +97,15 @@ pub(in crate::execution) enum ExecutionEvent<'a> { /// the operations that should be performed operations: Vec>, }, + /// A drive event paid from an asset lock with funds going to the shielded pool (with fee validation) + PaidFromAssetLockToPool { + /// Fee (asset_lock_value - shield_amount) to add to the fee pool + fees_to_add_to_pool: Credits, + /// the operations that should be performed + operations: Vec>, + /// the execution operations that we must also pay for + execution_operations: Vec, + }, /// A drive event that is free #[allow(dead_code)] // TODO investigate why `variant `Free` is never constructed` Free { @@ -442,6 +461,63 @@ impl ExecutionEvent<'_> { ))) } } + StateTransitionAction::ShieldAction(shield_action) => { + let user_fee_increase = shield_action.user_fee_increase(); + let input_current_balances = shield_action.inputs_with_remaining_balance().clone(); + let added_to_balance_outputs = BTreeMap::new(); + let fee_strategy = shield_action.fee_strategy().clone(); + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromAddressInputs { + input_current_balances, + added_to_balance_outputs, + fee_strategy, + operations, + execution_operations: execution_context.operations_consume(), + additional_fixed_fee_cost: None, + user_fee_increase, + }) + } + StateTransitionAction::ShieldedTransferAction(ref shielded_transfer_action) => { + let fee_amount = shielded_transfer_action.fee_amount(); + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromShieldedPool { + operations, + fees_to_add_to_pool: fee_amount, + }) + } + StateTransitionAction::UnshieldAction(ref unshield_action) => { + let fee_amount = unshield_action.fee_amount(); + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromShieldedPool { + operations, + fees_to_add_to_pool: fee_amount, + }) + } + StateTransitionAction::ShieldFromAssetLockAction(ref shield_from_asset_lock_action) => { + // Fee = asset_lock_value - shield_amount (excess from asset lock) + let fee_amount = shield_from_asset_lock_action + .asset_lock_value_to_be_consumed() + .saturating_sub(shield_from_asset_lock_action.shield_amount()); + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromAssetLockToPool { + fees_to_add_to_pool: fee_amount, + operations, + execution_operations: execution_context.operations_consume(), + }) + } + StateTransitionAction::ShieldedWithdrawalAction(ref shielded_withdrawal_action) => { + let fee_amount = shielded_withdrawal_action.fee_amount(); + let operations = + action.into_high_level_drive_operations(epoch, platform_version)?; + Ok(ExecutionEvent::PaidFromShieldedPool { + operations, + fees_to_add_to_pool: fee_amount, + }) + } _ => { let user_fee_increase = action.user_fee_increase(); let operations = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs index fe0fc422559..c887f12bcd9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs @@ -16,6 +16,7 @@ use crate::execution::check_tx::CheckTxLevel; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::common::asset_lock::proof::verify_is_not_spent::AssetLockProofVerifyIsNotSpent; use crate::execution::validation::state_transition::processor::address_witnesses::{StateTransitionAddressWitnessValidationV0, StateTransitionHasAddressWitnessValidationV0}; +use crate::execution::validation::state_transition::processor::traits::shielded_proof::{StateTransitionHasShieldedProofValidationV0, StateTransitionShieldedMinimumFeeValidationV0, StateTransitionShieldedProofValidationV0}; use crate::execution::validation::state_transition::processor::addresses_minimum_balance::StateTransitionAddressesMinimumBalanceValidationV0; use crate::execution::validation::state_transition::processor::advanced_structure_with_state::StateTransitionStructureKnownInStateValidationV0; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; @@ -132,6 +133,34 @@ pub(super) fn state_transition_to_execution_event_for_check_tx_v0<'a, C: CoreRPC } } + // Validate minimum fee for shielded spending transitions (stateless, uses public + // value_balance). This is cheaper than proof verification so we check it first. + // Only applies to ShieldedTransfer/Unshield/ShieldedWithdrawal — Shield pays from + // address inputs and ShieldFromAssetLock pays from the asset lock. + if state_transition.has_shielded_minimum_fee_validation() { + let result = state_transition.validate_minimum_shielded_fee(platform_version)?; + if !result.is_valid() { + return Ok( + ConsensusValidationResult::>::new_with_errors( + result.errors, + ), + ); + } + } + + // Verify ZK proof for shielded transitions (stateless, like signature verification). + // This happens before any state reads to reject invalid proofs cheaply. + if state_transition.has_shielded_proof_validation() { + let result = state_transition.validate_shielded_proof(platform_version)?; + if !result.is_valid() { + return Ok( + ConsensusValidationResult::>::new_with_errors( + result.errors, + ), + ); + } + } + // Only identity create does not use identity in state validation, because it doesn't yet have the identity in state let mut maybe_identity = if state_transition.uses_identity_in_state() { // Validating signature for identity based state transitions (all those except identity create and identity top up) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs index 21ce47e8173..555a14f4c00 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs @@ -11,6 +11,7 @@ use dpp::state_transition::state_transitions::identity::identity_topup_from_addr use dpp::state_transition::state_transitions::address_funds::address_funds_transfer_transition::AddressFundsTransferTransition; use dpp::state_transition::state_transitions::address_funds::address_funding_from_asset_lock_transition::AddressFundingFromAssetLockTransition; use dpp::state_transition::state_transitions::address_funds::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; +use dpp::state_transition::shield_transition::ShieldTransition; use drive::drive::Drive; use drive::error::Error; use drive::grovedb::TransactionArg; @@ -139,6 +140,7 @@ impl StateTransitionAddressBalancesAndNoncesInnerValidation { } impl StateTransitionAddressBalancesAndNoncesInnerValidation for AddressCreditWithdrawalTransition {} +impl StateTransitionAddressBalancesAndNoncesInnerValidation for ShieldTransition {} /// Trait for validating address balances and nonces in state transitions. pub trait StateTransitionAddressBalancesAndNoncesValidation { @@ -164,7 +166,8 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::AddressCreditWithdrawal(_) - | StateTransition::IdentityTopUpFromAddresses(_) => true, + | StateTransition::IdentityTopUpFromAddresses(_) + | StateTransition::Shield(_) => true, StateTransition::DataContractCreate(_) | StateTransition::IdentityCreate(_) | StateTransition::DataContractUpdate(_) @@ -174,14 +177,11 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::IdentityTopUp(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) - | StateTransition::IdentityCreditTransferToAddresses(_) => false, - StateTransition::Shield(_) + | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => false, } } @@ -229,6 +229,13 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { transaction, platform_version, ), + StateTransition::Shield(st) => st + .validate_address_balances_and_nonces_internal_validation( + drive, + execution_context, + transaction, + platform_version, + ), StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) @@ -238,15 +245,12 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) - | StateTransition::IdentityCreditTransferToAddresses(_) => { - Ok(ConsensusValidationResult::new_with_data(BTreeMap::new())) - } - StateTransition::Shield(_) + | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") + Ok(ConsensusValidationResult::new_with_data(BTreeMap::new())) } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs index 4ba9f70cdd1..3bdb3c0f53a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs @@ -68,6 +68,7 @@ impl StateTransitionAddressWitnessValidationV0 for StateTransition { StateTransition::AddressFundingFromAssetLock(st) => { st.validate_witnesses(&signable_bytes) } + StateTransition::Shield(st) => st.validate_witnesses(&signable_bytes), // These state transitions don't have address witness validation StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) @@ -78,15 +79,12 @@ impl StateTransitionAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) - | StateTransition::IdentityCreditTransferToAddresses(_) => { - return Ok(SimpleConsensusValidationResult::new()); - } - StateTransition::Shield(_) + | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") + return Ok(SimpleConsensusValidationResult::new()); } }; @@ -187,7 +185,8 @@ impl StateTransitionHasAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityCreateFromAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressCreditWithdrawal(_) - | StateTransition::AddressFundingFromAssetLock(_) => true, + | StateTransition::AddressFundingFromAssetLock(_) + | StateTransition::Shield(_) => true, StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) @@ -197,14 +196,11 @@ impl StateTransitionHasAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) - | StateTransition::IdentityCreditTransferToAddresses(_) => false, - StateTransition::Shield(_) + | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => false, }; Ok(has_address_witness_validation) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs index 589c90bd428..be839b5d33a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs @@ -58,6 +58,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { transition.validate_estimated_fee(remaining_address_balances, platform_version) } // AddressFundingFromAssetLock doesn't need balance check - funds come from asset lock + // Shielded transitions don't use address minimum balance validation // All other state transitions don't use address minimum balance validation StateTransition::AddressFundingFromAssetLock(_) | StateTransition::DataContractCreate(_) @@ -69,15 +70,13 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::Batch(_) - | StateTransition::MasternodeVote(_) => { - return Ok(SimpleConsensusValidationResult::new()); - } - StateTransition::Shield(_) + | StateTransition::MasternodeVote(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") + return Ok(SimpleConsensusValidationResult::new()); } }?; @@ -103,14 +102,12 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::Batch(_) | StateTransition::MasternodeVote(_) - | StateTransition::AddressFundingFromAssetLock(_) => false, - StateTransition::Shield(_) + | StateTransition::AddressFundingFromAssetLock(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => false, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs index 0eefb307c9b..8afa787ecad 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs @@ -237,12 +237,121 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { })), } } - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") + StateTransition::Shield(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield transition: validate_basic_structure".to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "shield transition: validate_basic_structure".to_string(), + known_versions: vec![0], + })), + } + } + StateTransition::ShieldedTransfer(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shielded_transfer_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shielded transfer transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "shielded transfer transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + })), + } + } + StateTransition::Unshield(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .unshield_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "unshield transition: validate_basic_structure".to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "unshield transition: validate_basic_structure".to_string(), + known_versions: vec![0], + })), + } + } + StateTransition::ShieldFromAssetLock(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_asset_lock_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield from asset lock transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "shield from asset lock transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + })), + } + } + StateTransition::ShieldedWithdrawal(st) => { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shielded_withdrawal_state_transition + .basic_structure + { + Some(0) => Ok(st.validate_structure(platform_version)), + Some(version) => { + Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shielded withdrawal transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + received: version, + })) + } + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "shielded withdrawal transition: validate_basic_structure" + .to_string(), + known_versions: vec![0], + })), + } } } } @@ -280,14 +389,42 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::AddressCreditWithdrawal(_) => true, + StateTransition::Shield(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_state_transition + .basic_structure + .is_some(), + StateTransition::ShieldedTransfer(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shielded_transfer_state_transition + .basic_structure + .is_some(), + StateTransition::Unshield(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .unshield_state_transition + .basic_structure + .is_some(), + StateTransition::ShieldFromAssetLock(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_asset_lock_state_transition + .basic_structure + .is_some(), + StateTransition::ShieldedWithdrawal(_) => platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shielded_withdrawal_state_transition + .basic_structure + .is_some(), StateTransition::MasternodeVote(_) => false, - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index d9bab90f70b..5e86b7e9618 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -76,16 +76,12 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) - | StateTransition::AddressCreditWithdrawal(_) => { - Ok(SimpleConsensusValidationResult::new()) - } - StateTransition::Shield(_) + | StateTransition::AddressCreditWithdrawal(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => Ok(SimpleConsensusValidationResult::new()), } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 289f087f664..c181776c722 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -130,14 +130,12 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) - | StateTransition::AddressCreditWithdrawal(_) => Ok(ConsensusValidationResult::new()), - StateTransition::Shield(_) + | StateTransition::AddressCreditWithdrawal(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => Ok(ConsensusValidationResult::new()), } } @@ -172,7 +170,12 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityCreateFromAddresses(_) | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) - | StateTransition::AddressCreditWithdrawal(_) => false, + | StateTransition::AddressCreditWithdrawal(_) + | StateTransition::Shield(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldFromAssetLock(_) + | StateTransition::ShieldedWithdrawal(_) => false, StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) @@ -183,13 +186,6 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::MasternodeVote(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) => true, - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } } } @@ -202,7 +198,12 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::AddressCreditWithdrawal(_) | StateTransition::IdentityTopUpFromAddresses(_) - | StateTransition::IdentityTopUp(_) => false, + | StateTransition::IdentityTopUp(_) + | StateTransition::Shield(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldFromAssetLock(_) + | StateTransition::ShieldedWithdrawal(_) => false, StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::Batch(_) @@ -211,13 +212,6 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) | StateTransition::IdentityCreditTransferToAddresses(_) => true, - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs index 2803fda3947..f84d263e5af 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs @@ -114,14 +114,12 @@ impl StateTransitionIdentityNonceValidationV0 for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundsTransfer(_) | StateTransition::IdentityCreate(_) - | StateTransition::IdentityTopUp(_) => Ok(SimpleConsensusValidationResult::new()), - StateTransition::Shield(_) + | StateTransition::IdentityTopUp(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => Ok(SimpleConsensusValidationResult::new()), } } } @@ -167,14 +165,12 @@ impl StateTransitionHasIdentityNonceValidationV0 for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) - | StateTransition::AddressCreditWithdrawal(_) => false, - StateTransition::Shield(_) + | StateTransition::AddressCreditWithdrawal(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => false, }; Ok(has_nonce_validation) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 22af15ae8f0..7d45224ed2c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -5,7 +5,9 @@ use crate::rpc::core::CoreRPCLike; use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::StateTransition; -use dpp::version::feature_initial_protocol_versions::ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION; +use dpp::version::feature_initial_protocol_versions::{ + ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, +}; use dpp::version::PlatformVersion; /// A trait for validating state transitions within a blockchain. @@ -29,7 +31,12 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::AddressFundsTransfer(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::AddressFundingFromAssetLock(_) - | StateTransition::AddressCreditWithdrawal(_) => Ok(true), + | StateTransition::AddressCreditWithdrawal(_) + | StateTransition::Shield(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldFromAssetLock(_) + | StateTransition::ShieldedWithdrawal(_) => Ok(true), StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::IdentityCreate(_) @@ -38,13 +45,6 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) => Ok(false), - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } } } @@ -74,6 +74,24 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { ])) } } + StateTransition::Shield(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldFromAssetLock(_) + | StateTransition::ShieldedWithdrawal(_) => { + if platform_version.protocol_version >= SHIELDED_POOL_INITIAL_PROTOCOL_VERSION { + Ok(ConsensusValidationResult::new()) + } else { + Ok(ConsensusValidationResult::new_with_errors(vec![ + StateTransitionNotActiveError::new( + self.state_transition_type().to_string(), + platform_version.protocol_version, + SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + ) + .into(), + ])) + } + } _ => Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "validate_is_allowed is not implemented for this state transition", ))), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/mod.rs index d23d32c3e06..b4767c7ee9d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/mod.rs @@ -10,4 +10,5 @@ pub(crate) mod identity_based_signature; pub(crate) mod identity_nonces; pub(crate) mod is_allowed; pub(crate) mod prefunded_specialized_balance; +pub(crate) mod shielded_proof; pub(crate) mod state; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs new file mode 100644 index 00000000000..b61ad5132fe --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -0,0 +1,260 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + reconstruct_and_verify_bundle, FLAGS_OUTPUTS_ONLY, FLAGS_SPENDS_AND_OUTPUTS, +}; +use dpp::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; +use dpp::consensus::state::state_error::StateError; +use dpp::shielded::SHIELDED_STORAGE_BYTES_PER_ACTION; +use dpp::state_transition::StateTransition; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; + +/// A trait for checking whether a state transition requires shielded ZK proof validation. +pub(crate) trait StateTransitionHasShieldedProofValidationV0 { + /// Returns true if this state transition has a ZK proof that must be verified + /// before any state reads. + fn has_shielded_proof_validation(&self) -> bool; + + /// Returns true if this state transition pays fees from the shielded pool's + /// value_balance and requires minimum fee validation. + /// + /// Shield pays fees from transparent address inputs, and ShieldFromAssetLock + /// pays from the asset lock, so neither goes through shielded fee validation. + fn has_shielded_minimum_fee_validation(&self) -> bool; +} + +/// A trait for validating the ZK proof of a shielded state transition. +/// +/// This is a stateless check — it only uses data from the transition itself +/// (actions, flags, value_balance, anchor bytes, proof, binding_signature). +/// No GroveDB reads are needed. +pub(crate) trait StateTransitionShieldedProofValidationV0 { + fn validate_shielded_proof( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl StateTransitionHasShieldedProofValidationV0 for StateTransition { + fn has_shielded_proof_validation(&self) -> bool { + // Note: ShieldFromAssetLock is intentionally excluded. Its proof verification + // is done inside transform_into_action because a failed proof must penalize + // the asset lock (via PartiallyUseAssetLockAction). Moving it here would let + // attackers spam bad proofs without burning their asset lock. + matches!( + self, + StateTransition::Shield(_) + | StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldedWithdrawal(_) + ) + } + + fn has_shielded_minimum_fee_validation(&self) -> bool { + // Only spending transitions pay fees from the shielded pool. + // Shield pays from address inputs; ShieldFromAssetLock pays from the asset lock. + matches!( + self, + StateTransition::ShieldedTransfer(_) + | StateTransition::Unshield(_) + | StateTransition::ShieldedWithdrawal(_) + ) + } +} + +/// A trait for validating that a shielded state transition includes sufficient fees. +/// +/// The minimum fee is computed dynamically based on the number of actions: +/// min_fee = proof_verification_fee + num_actions × (processing_fee + storage_fee) +/// +/// The fee is derived from the public `value_balance` field (no ZK proof execution needed): +/// - ShieldedTransfer: fee = value_balance +/// - Unshield: fee = value_balance - amount +/// - ShieldedWithdrawal: fee = value_balance - amount +/// - Shield: fee paid by transparent address inputs (skipped here) +pub(crate) trait StateTransitionShieldedMinimumFeeValidationV0 { + fn validate_minimum_shielded_fee( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl StateTransitionShieldedMinimumFeeValidationV0 for StateTransition { + fn validate_minimum_shielded_fee( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .validate_minimum_shielded_fee + { + 0 => { + // Extract the fee and action count from the transition. + let (fee, num_actions): (i64, usize) = match self { + // Shield: fee is paid from transparent address inputs, not from value_balance. + StateTransition::Shield(_) => { + return Ok(SimpleConsensusValidationResult::new()) + } + // ShieldedTransfer: value_balance (u64) IS the fee. + StateTransition::ShieldedTransfer(st) => match st { + dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { + (v0.value_balance as i64, v0.actions.len()) + } + }, + // Unshield: fee = value_balance - amount. + StateTransition::Unshield(st) => match st { + dpp::state_transition::unshield_transition::UnshieldTransition::V0( + v0, + ) => { + // unshielding_amount is the total leaving the pool (fee is validated separately) + (v0.unshielding_amount as i64, v0.actions.len()) + } + }, + StateTransition::ShieldedWithdrawal(st) => match st { + dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition::V0(v0) => { + (v0.unshielding_amount as i64, v0.actions.len()) + } + }, + // Other transitions don't go through shielded fee validation. + _ => return Ok(SimpleConsensusValidationResult::new()), + }; + + let constants = &platform_version + .drive_abci + .validation_and_processing + .event_constants; + + // Storage fee per action: 312 bytes (280 BulkAppendTree + 32 nullifier) + // × (storage_disk_usage_credit_per_byte + storage_processing_credit_per_byte) + let storage_costs = &platform_version.fee_version.storage; + let storage_fee_per_action = SHIELDED_STORAGE_BYTES_PER_ACTION + * (storage_costs.storage_disk_usage_credit_per_byte + + storage_costs.storage_processing_credit_per_byte); + + // min_fee = proof_verification_fee + num_actions × (processing_fee + storage_fee) + let per_action_fee = + constants.shielded_per_action_processing_fee + storage_fee_per_action; + let minimum_shielded_fee = + constants.shielded_proof_verification_fee + num_actions as u64 * per_action_fee; + + if (fee as u64) < minimum_shielded_fee { + Ok(SimpleConsensusValidationResult::new_with_error( + StateError::InsufficientShieldedFeeError( + InsufficientShieldedFeeError::new(format!( + "shielded transition fee {} is below minimum required fee {} \ + ({} proof + {} actions × {} per-action)", + fee, + minimum_shielded_fee, + constants.shielded_proof_verification_fee, + num_actions, + per_action_fee, + )), + ) + .into(), + )) + } else { + Ok(SimpleConsensusValidationResult::new()) + } + } + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "StateTransition::validate_minimum_shielded_fee".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +impl StateTransitionShieldedProofValidationV0 for StateTransition { + fn validate_shielded_proof( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .validate_shielded_proof + { + 0 => { + let result = match self { + StateTransition::Shield(st) => match st { + dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_OUTPUTS_ONLY, + -(v0.amount as i64), + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &[], // No transparent fields for shield + ) + } + }, + StateTransition::ShieldedTransfer(st) => match st { + dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition::V0(v0) => { + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_SPENDS_AND_OUTPUTS, + v0.value_balance as i64, + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &[], // No transparent fields for shielded transfer + ) + } + }, + StateTransition::Unshield(st) => match st { + dpp::state_transition::unshield_transition::UnshieldTransition::V0(v0) => { + let mut extra_sighash_data = v0.output_address.to_bytes(); + extra_sighash_data + .extend_from_slice(&v0.unshielding_amount.to_le_bytes()); + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_SPENDS_AND_OUTPUTS, + v0.unshielding_amount as i64, + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &extra_sighash_data, + ) + } + }, + StateTransition::ShieldedWithdrawal(st) => match st { + dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition::V0(v0) => { + let mut extra_sighash_data = + v0.output_script.as_bytes().to_vec(); + extra_sighash_data + .extend_from_slice(&v0.unshielding_amount.to_le_bytes()); + reconstruct_and_verify_bundle( + &v0.actions, + FLAGS_SPENDS_AND_OUTPUTS, + v0.unshielding_amount as i64, + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + &extra_sighash_data, + ) + } + }, + // ShieldFromAssetLock retains proof verification in transform_into_action + // (penalty comes from the asset lock, which is safe) + _ => return Ok(SimpleConsensusValidationResult::new()), + }; + + match result { + Ok(()) => Ok(SimpleConsensusValidationResult::new()), + Err(e) => Ok(SimpleConsensusValidationResult::new_with_error( + StateError::InvalidShieldedProofError(e).into(), + )), + } + } + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "StateTransition::validate_shielded_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 5c426029c23..b2f92fa7e84 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -185,12 +185,26 @@ impl StateTransitionStateValidation for StateTransition { "address credit withdrawal should not have state validation", ))) } - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") + StateTransition::Shield(_) => Err(Error::Execution( + ExecutionError::CorruptedCodeExecution("shield should not have state validation"), + )), + StateTransition::ShieldedTransfer(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "shielded transfer should not have state validation", + ))) + } + StateTransition::Unshield(_) => Err(Error::Execution( + ExecutionError::CorruptedCodeExecution("unshield should not have state validation"), + )), + StateTransition::ShieldFromAssetLock(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "shield from asset lock should not have state validation", + ))) + } + StateTransition::ShieldedWithdrawal(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "shielded withdrawal should not have state validation", + ))) } } } @@ -211,14 +225,12 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::AddressCreditWithdrawal(_) - | StateTransition::IdentityCreditTransferToAddresses(_) => false, - StateTransition::Shield(_) + | StateTransition::IdentityCreditTransferToAddresses(_) + | StateTransition::Shield(_) | StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") - } + | StateTransition::ShieldedWithdrawal(_) => false, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index 8765733d5eb..eb5ca95d0d0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -17,6 +17,10 @@ use crate::execution::validation::state_transition::processor::identity_nonces:: use crate::execution::validation::state_transition::processor::is_allowed::StateTransitionIsAllowedValidationV0; use crate::execution::validation::state_transition::processor::prefunded_specialized_balance::StateTransitionPrefundedSpecializedBalanceValidationV0; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; +use crate::execution::validation::state_transition::processor::traits::shielded_proof::{ + StateTransitionHasShieldedProofValidationV0, StateTransitionShieldedMinimumFeeValidationV0, + StateTransitionShieldedProofValidationV0, +}; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; @@ -215,6 +219,25 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( None }; + // Validate minimum fee for shielded spending transitions (stateless, uses public value_balance). + // This is cheaper than proof verification so we check it first. + // Only applies to ShieldedTransfer/Unshield/ShieldedWithdrawal — Shield pays from address + // inputs and ShieldFromAssetLock pays from the asset lock. + if state_transition.has_shielded_minimum_fee_validation() { + let result = state_transition.validate_minimum_shielded_fee(platform_version)?; + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Verify ZK proof for shielded transitions (stateless, like signature verification). + if state_transition.has_shielded_proof_validation() { + let result = state_transition.validate_shielded_proof(platform_version)?; + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + // Only identity update and data contract create have advanced structure validation without state if state_transition.has_advanced_structure_validation_without_state() { // Currently only used for Identity Update, Data Contract Create and Identity Create From Addresses diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs index 274481decb1..87d4c6d16ff 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs @@ -7489,8 +7489,6 @@ mod tests { ); // Assert exact values - UPDATE THESE if fees legitimately change - // Fee increased by 51,300 in protocol v12 due to shielded pool trees - // increasing Merk propagation costs in AddressBalances subtree. assert_eq!( processing_fee, 508740, "Processing fee changed! Was 508740, now {}", @@ -8023,10 +8021,10 @@ mod tests { processing_fee, storage_fee, total_fee ); - // Base processing fee is 508740, with user_fee_increase=100 it should be higher + // Base processing fee is 457440, with user_fee_increase=100 it should be higher // The exact formula depends on implementation assert!( - processing_fee > 508740, + processing_fee > 457440, "Processing fee with user_fee_increase should be higher than base" ); @@ -8121,9 +8119,9 @@ mod tests { processing_fee, storage_fee, total_fee ); - // 16 inputs should have higher processing fee than 1 input (base is ~508K) + // 16 inputs should have higher processing fee than 1 input (base is ~457K) assert!( - processing_fee > 508740, + processing_fee > 457440, "16 inputs should have processing fee > single input" ); @@ -8288,16 +8286,9 @@ mod tests { ); } - // ========================================== - // PROTOCOL V11 FEE REGRESSION TESTS - // These ensure fees match pre-shielded-pool values when running - // under protocol v11 (which uses create_initial_state_structure v2, - // without the shielded pool trees that increase Merk propagation costs). - // ========================================== - #[test] - fn test_fee_simple_p2pkh_1_input_1_output_deduct_from_input_in_protocol_v11() { - let platform_version = PlatformVersion::get(11).unwrap(); + fn test_fee_simple_p2pkh_1_input_1_output_deduct_from_input_on_version_11() { + let platform_version = PlatformVersion::get(11).expect("expected version 11"); let platform_config = PlatformConfig { testing_configs: PlatformTestConfig { disable_instant_lock_signature_verification: true, @@ -8356,27 +8347,31 @@ mod tests { let (processing_fee, storage_fee, total_fee) = extract_fees(&processing_result.execution_results()[0]); - // Protocol v11 does not have shielded pool trees, so fees are lower + println!( + "V11 P2PKH 1-in-1-out DeductFromInput: processing={}, storage={}, total={}", + processing_fee, storage_fee, total_fee + ); + assert_eq!( processing_fee, 457440, - "Protocol v11 processing fee changed! Was 457440, now {}", + "Processing fee changed! Was 457440, now {}", processing_fee ); assert_eq!( storage_fee, 6075000, - "Protocol v11 storage fee changed! Was 6075000, now {}", + "Storage fee changed! Was 6075000, now {}", storage_fee ); assert_eq!( total_fee, 6532440, - "Protocol v11 total fee changed! Was 6532440, now {}", + "Total fee changed! Was 6532440, now {}", total_fee ); } #[test] - fn test_fee_simple_p2pkh_1_input_1_output_reduce_output_in_protocol_v11() { - let platform_version = PlatformVersion::get(11).unwrap(); + fn test_fee_p2pkh_2_inputs_1_output_on_version_11() { + let platform_version = PlatformVersion::get(11).expect("expected version 11"); let platform_config = PlatformConfig { testing_configs: PlatformTestConfig { disable_instant_lock_signature_verification: true, @@ -8392,22 +8387,25 @@ mod tests { .set_genesis_state(); let mut signer = TestAddressSigner::new(); - let input_address = signer.add_p2pkh([1u8; 32]); + let input_address1 = signer.add_p2pkh([1u8; 32]); + let input_address2 = signer.add_p2pkh([2u8; 32]); let output_address = create_platform_address(99); let transfer_amount = dash_to_credits!(0.5); let initial_balance = dash_to_credits!(1.0); - setup_address_with_balance(&mut platform, input_address, 0, initial_balance); + setup_address_with_balance(&mut platform, input_address1, 0, initial_balance); + setup_address_with_balance(&mut platform, input_address2, 0, initial_balance); let mut inputs = BTreeMap::new(); - inputs.insert(input_address, (1 as AddressNonce, transfer_amount)); + inputs.insert(input_address1, (1 as AddressNonce, transfer_amount)); + inputs.insert(input_address2, (1 as AddressNonce, transfer_amount)); let mut outputs = BTreeMap::new(); - outputs.insert(output_address, transfer_amount); + outputs.insert(output_address, transfer_amount * 2); let transition = AddressFundsTransferTransitionV0::try_from_inputs_with_signer( inputs, outputs, - vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], &signer, 0, platform_version, @@ -8435,27 +8433,31 @@ mod tests { let (processing_fee, storage_fee, total_fee) = extract_fees(&processing_result.execution_results()[0]); - // Protocol v11 does not have shielded pool trees, so fees are lower + println!( + "V11 P2PKH 2-in-1-out: processing={}, storage={}, total={}", + processing_fee, storage_fee, total_fee + ); + assert_eq!( - processing_fee, 457440, - "Protocol v11 processing fee changed! Was 457440, now {}", + processing_fee, 587800, + "Processing fee changed! Was 587800, now {}", processing_fee ); assert_eq!( storage_fee, 6075000, - "Protocol v11 storage fee changed! Was 6075000, now {}", + "Storage fee changed! Was 6075000, now {}", storage_fee ); assert_eq!( - total_fee, 6532440, - "Protocol v11 total fee changed! Was 6532440, now {}", + total_fee, 6662800, + "Total fee changed! Was 6662800, now {}", total_fee ); } #[test] - fn test_fee_with_user_fee_increase_in_protocol_v11() { - let platform_version = PlatformVersion::get(11).unwrap(); + fn test_fee_p2sh_2_of_3_multisig_on_version_11() { + let platform_version = PlatformVersion::get(11).expect("expected version 11"); let platform_config = PlatformConfig { testing_configs: PlatformTestConfig { disable_instant_lock_signature_verification: true, @@ -8471,7 +8473,17 @@ mod tests { .set_genesis_state(); let mut signer = TestAddressSigner::new(); - let input_address = signer.add_p2pkh([1u8; 32]); + + let seeds: Vec<[u8; 32]> = (1..=3) + .map(|i| { + let mut seed = [0u8; 32]; + seed[0] = i; + seed[31] = i; + seed + }) + .collect(); + + let input_address = signer.add_p2sh_multisig(2, &seeds); let output_address = create_platform_address(99); let amount = dash_to_credits!(1.0); @@ -8482,14 +8494,12 @@ mod tests { let mut outputs = BTreeMap::new(); outputs.insert(output_address, amount); - let user_fee_increase = 100; - let transition = AddressFundsTransferTransitionV0::try_from_inputs_with_signer( inputs, outputs, vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], &signer, - user_fee_increase, + 0, platform_version, ) .expect("should create transition"); @@ -8515,20 +8525,24 @@ mod tests { let (processing_fee, storage_fee, total_fee) = extract_fees(&processing_result.execution_results()[0]); - // Protocol v11 does not have shielded pool trees + println!( + "V11 P2SH 2-of-3 multisig 1-in-1-out: processing={}, storage={}, total={}", + processing_fee, storage_fee, total_fee + ); + assert_eq!( - processing_fee, 914880, - "Protocol v11 processing fee changed! Was 914880, now {}", + processing_fee, 477440, + "Processing fee changed! Was 477440, now {}", processing_fee ); assert_eq!( storage_fee, 6075000, - "Protocol v11 storage fee changed! Was 6075000, now {}", + "Storage fee changed! Was 6075000, now {}", storage_fee ); assert_eq!( - total_fee, 6989880, - "Protocol v11 total fee changed! Was 6989880, now {}", + total_fee, 6552440, + "Total fee changed! Was 6552440, now {}", total_fee ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 95ec1d27704..5ec952feba8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -41,6 +41,19 @@ pub mod address_credit_withdrawal; pub mod address_funds_transfer; mod identity_top_up_from_addresses; +/// Module for shield transition validation +pub mod shield; +/// Module for shield from asset lock transition validation +pub mod shield_from_asset_lock; +/// Common validation logic shared by shielded transitions (proof verification) +pub mod shielded_common; +/// Module for shielded transfer transition validation +pub mod shielded_transfer; +/// Module for shielded withdrawal transition validation +pub mod shielded_withdrawal; +/// Module for unshield transition validation +pub mod unshield; + /// The validation mode we are using #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ValidationMode { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs new file mode 100644 index 00000000000..9c840487a4a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs @@ -0,0 +1,70 @@ +#[cfg(test)] +mod tests; +mod transform_into_action; + +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::prelude::AddressNonce; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; +use std::collections::BTreeMap; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::shield::transform_into_action::v0::ShieldStateTransitionTransformIntoActionValidationV0; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use crate::platform_types::platform_state::PlatformStateV0Methods; + +/// A trait to transform into an action for shield transition +pub trait StateTransitionShieldTransitionActionTransformer { + /// Transform into an action for shield transition + fn transform_into_action_for_shield_transition( + &self, + platform: &PlatformRef, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionShieldTransitionActionTransformer for ShieldTransition { + fn transform_into_action_for_shield_transition( + &self, + platform: &PlatformRef, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform.drive, + tx, + inputs_with_remaining_balance, + block_info, + execution_context, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs new file mode 100644 index 00000000000..e5663fd7dce --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -0,0 +1,1312 @@ +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, create_dummy_witness, create_platform_address, + process_transition, setup_address_with_balance, setup_platform, TestAddressSigner, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use assert_matches::assert_matches; + use dpp::address_funds::{ + AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, AddressWitness, PlatformAddress, + }; + use dpp::consensus::basic::BasicError; + use dpp::consensus::signature::SignatureError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::dash_to_credits; + use dpp::fee::Credits; + use dpp::identity::signer::Signer; + use dpp::prelude::AddressNonce; + use dpp::serialization::{PlatformSerializable, Signable}; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::shield_transition::ShieldTransition; + use dpp::state_transition::StateTransition; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, DashMemo, + Flags as OrchardFlags, FullViewingKey, NoteValue, ProvingKey, Scope, SpendingKey, + }; + use platform_version::version::PlatformVersion; + use rand::rngs::OsRng; + use std::collections::BTreeMap; + use std::sync::OnceLock; + + // ========================================== + // Helper Functions (transition-specific) + // ========================================== + + /// Builds a raw `ShieldTransitionV0` with dummy witnesses. Used for structure validation tests + /// that don't need valid signatures (the structure error is caught before or alongside witness + /// validation, or inputs are empty so witness validation is vacuously true). + fn create_raw_shield_transition( + inputs: BTreeMap, + actions: Vec, + amount: u64, + proof: Vec, + binding_signature: [u8; 64], + fee_strategy: AddressFundsFeeStrategy, + witness_count: usize, + ) -> StateTransition { + let witnesses: Vec = + (0..witness_count).map(|_| create_dummy_witness()).collect(); + StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs, + actions, + amount, + anchor: [0u8; 32], + proof, + binding_signature, + fee_strategy, + user_fee_increase: 0, + input_witnesses: witnesses, + })) + } + + /// Builds a `ShieldTransitionV0` and signs it with the provided signer. + /// The transition will have valid witnesses for all inputs. + fn create_signed_shield_transition( + signer: &TestAddressSigner, + inputs: BTreeMap, + actions: Vec, + amount: u64, + proof: Vec, + binding_signature: [u8; 64], + fee_strategy: AddressFundsFeeStrategy, + ) -> StateTransition { + // First create with empty witnesses to compute signable bytes + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions, + amount, + anchor: [42u8; 32], + proof, + binding_signature, + fee_strategy, + user_fee_increase: 0, + input_witnesses: vec![], + })); + + // Compute signable bytes (excludes input_witnesses due to #[platform_signable(exclude_from_sig_hash)]) + let signable_bytes = st.signable_bytes().expect("should compute signable bytes"); + + // Sign each input with the signer + let witnesses: Vec = inputs + .keys() + .map(|address| { + signer + .sign_create_witness(address, &signable_bytes) + .expect("should sign") + }) + .collect(); + + // Inject witnesses + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + st + } + + /// Shorthand for creating a structurally valid (but cryptographically invalid) signed shield + /// transition with a single input address. The ZK proof data is random/dummy. + fn create_default_signed_shield_transition( + signer: &TestAddressSigner, + input_address: PlatformAddress, + input_nonce: AddressNonce, + input_amount: Credits, + ) -> StateTransition { + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (input_nonce, input_amount)); + + create_signed_shield_transition( + signer, + inputs, + vec![create_dummy_serialized_action()], + 1000, + vec![0u8; 100], // dummy proof bytes + [0u8; 64], // dummy binding signature + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]), + ) + } + + // ========================================== + // Orchard Proving Key (cached, ~30s to build) + // ========================================== + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + /// Extract serialized fields from an authorized Orchard bundle into the + /// platform-compatible format: (actions, flags, value_balance, anchor, proof, binding_sig). + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u8, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + + let flags = bundle.flags().to_byte(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + + (actions, flags, value_balance, anchor, proof, binding_sig) + } + + // ========================================== + // STRUCTURE VALIDATION TESTS (BasicError) + // ========================================== + + mod structure_validation { + use super::*; + + #[test] + fn test_empty_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + // Need a properly signed transition with address in state so we get past + // witness and address validation + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![], // Empty actions — invalid + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedNoActionsError(_)) + )] + ); + } + + #[test] + fn test_no_inputs_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Empty inputs — witness and address validation are vacuously true + let transition = create_raw_shield_transition( + BTreeMap::new(), // no inputs + vec![create_dummy_serialized_action()], + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + 0, // 0 witnesses to match 0 inputs + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::TransitionNoInputsError(_)) + )] + ); + } + + #[test] + fn test_witness_count_mismatch_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // Create a properly signed transition (1 input, 1 valid witness) + let mut transition = create_default_signed_shield_transition( + &signer, + input_address, + 1, + dash_to_credits!(0.5), + ); + + // Add an extra dummy witness to cause mismatch (1 input, 2 witnesses) + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + v0.input_witnesses.push(create_dummy_witness()); + } + + let processing_result = process_transition(&platform, transition, platform_version); + + // Witness validation runs before structure validation in the pipeline, + // so count mismatch is caught as a SignatureError, not a BasicError. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError( + SignatureError::InvalidStateTransitionSignatureError(_) + ) + )] + ); + } + + #[test] + fn test_input_below_minimum_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, 1)); // 1 credit — below minimum + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::InputBelowMinimumError(_)) + )] + ); + } + + /// Tests validate_structure directly because 101 actions exceed the + /// max_state_transition_size (20KB) before reaching the actions count check + /// in the full pipeline. + #[test] + fn test_too_many_actions_returns_error() { + use dpp::state_transition::StateTransitionStructureValidation; + + let platform_version = PlatformVersion::latest(); + + // 101 actions exceeds max_shielded_transition_actions (100) + let actions: Vec = + (0..101).map(|_| create_dummy_serialized_action()).collect(); + + let transition = ShieldTransitionV0 { + inputs: BTreeMap::new(), + actions, + amount: 1000, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + }; + + let result = transition.validate_structure(platform_version); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedTooManyActionsError(_) + )] + ); + } + + #[test] + fn test_amount_exceeding_i64_max_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + i64::MAX as u64 + 1, // Exceeds i64::MAX + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_zero_value_balance_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 0, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1000, + vec![], // Empty proof — invalid + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedEmptyProofError(_)) + )] + ); + } + + #[test] + fn test_empty_fee_strategy_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![]), // Empty fee strategy + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::FeeStrategyEmptyError(_)) + )] + ); + } + + #[test] + fn test_fee_strategy_too_many_steps_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + // More steps than the max allowed (typically 4) + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + AddressFundsFeeStrategyStep::DeductFromInput(1), + AddressFundsFeeStrategyStep::DeductFromInput(2), + AddressFundsFeeStrategyStep::DeductFromInput(3), + AddressFundsFeeStrategyStep::DeductFromInput(4), + ]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::FeeStrategyTooManyStepsError(_)) + )] + ); + } + + #[test] + fn test_fee_strategy_duplicate_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + AddressFundsFeeStrategyStep::DeductFromInput(0), // Duplicate + ]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::FeeStrategyDuplicateError(_)) + )] + ); + } + } + + // ========================================== + // WITNESS VALIDATION TESTS (SignatureError) + // ========================================== + + mod witness_validation { + use super::*; + + #[test] + fn test_invalid_witness_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // Create properly signed transition, then tamper with the witness + let mut transition = create_default_signed_shield_transition( + &signer, + input_address, + 1, + dash_to_credits!(0.5), + ); + + // Tamper the witness signature + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let Some(AddressWitness::P2pkh { ref mut signature }) = + v0.input_witnesses.first_mut() + { + // Flip a byte in the signature + if let Some(byte) = signature.0.first_mut() { + *byte ^= 0xFF; + } + } + } + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError( + SignatureError::InvalidStateTransitionSignatureError(_) + ) + )] + ); + } + + #[test] + fn test_wrong_key_witness_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // Create a second signer with different key + let mut wrong_signer = TestAddressSigner::new(); + let _wrong_address = wrong_signer.add_p2pkh([2u8; 32]); + + // Build transition for the real input address but sign with wrong key's signer + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + // The wrong_signer doesn't have input_address, so we manually create a bad witness + let mut transition = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + // Replace the valid witness with one signed by a different key + let signable_bytes = transition + .signable_bytes() + .expect("should compute signable bytes"); + let wrong_witness = wrong_signer + .sign_create_witness(&_wrong_address, &signable_bytes) + .expect("should sign"); + + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + v0.input_witnesses = vec![wrong_witness]; + } + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError( + SignatureError::InvalidStateTransitionSignatureError(_) + ) + )] + ); + } + } + + // ========================================== + // ADDRESS STATE VALIDATION TESTS (StateError) + // ========================================== + + mod address_state_validation { + use super::*; + + #[test] + fn test_address_not_found_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + // NOTE: No setup_address_with_balance — address does not exist in state + + let transition = create_default_signed_shield_transition( + &signer, + input_address, + 1, + dash_to_credits!(0.5), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::AddressDoesNotExistError(_)) + )] + ); + } + + #[test] + fn test_wrong_nonce_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + // Set up address with nonce 0 + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // Create transition with nonce 5 (expected nonce is 1) + let transition = create_default_signed_shield_transition( + &signer, + input_address, + 5, // Wrong nonce — state has 0, expected next is 1 + dash_to_credits!(0.5), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::AddressInvalidNonceError(_)) + )] + ); + } + + #[test] + fn test_insufficient_balance_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + // Set up address with small balance + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(0.001)); + + // Try to shield more than the balance + let transition = create_default_signed_shield_transition( + &signer, + input_address, + 1, + dash_to_credits!(1.0), // Way more than 0.001 Dash balance + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::AddressNotEnoughFundsError(_)) + )] + ); + } + } + + // ========================================== + // ZK PROOF VERIFICATION TESTS (InvalidShieldedProofError) + // ========================================== + + mod proof_verification { + use super::*; + + #[test] + fn test_invalid_proof_returns_shielded_proof_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // This transition is structurally valid but has random ZK proof data. + // It should pass structure validation, witness validation, and address validation + // but fail at proof verification in transform_into_action. + let transition = create_default_signed_shield_transition( + &signer, + input_address, + 1, + dash_to_credits!(0.5), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // The proof verification happens during transform_into_action. + // With random data, reconstruct_and_verify_bundle should fail at + // parsing the cryptographic fields (nullifier, rk, cmx, cv_net) or + // at the actual proof verification step. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_valid_shield_proof_succeeds() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + // --- Set up input address with enough balance --- + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // --- Build valid Orchard bundle (shield = outputs only) --- + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + let shield_value = 5000u64; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + // --- Extract serialized fields from the authorized bundle --- + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be negative for shield (money going into pool) + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + // --- Build and sign the shield transition --- + let mut inputs = BTreeMap::new(); + inputs.insert( + input_address, + (1 as AddressNonce, shield_amount + dash_to_credits!(0.01)), + ); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions, + amount: shield_amount, + anchor: anchor_bytes, + proof: proof_bytes, + binding_signature: binding_sig, + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + + let signable_bytes = st.signable_bytes().expect("should compute signable bytes"); + let witnesses: Vec = inputs + .keys() + .map(|address| { + signer + .sign_create_witness(address, &signable_bytes) + .expect("should sign") + }) + .collect(); + + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + + let processing_result = process_transition(&platform, st, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[test] + fn test_wrong_encrypted_note_size_returns_error() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // Create action with wrong encrypted_note size + let mut bad_action = create_dummy_serialized_action(); + bad_action.encrypted_note = vec![0u8; 100]; // 100 bytes instead of 216 + + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, dash_to_credits!(0.5))); + + let transition = create_signed_shield_transition( + &signer, + inputs, + vec![bad_action], + 1000, + vec![0u8; 100], + [0u8; 64], + AddressFundsFeeStrategy::from(vec![AddressFundsFeeStrategyStep::DeductFromInput( + 0, + )]), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // The encrypted_note size check happens in reconstruct_and_verify_bundle, + // which now runs at the processor level before state validation. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // SECURITY AUDIT TESTS + // ========================================== + + mod security_audit { + use super::*; + + /// Zero anchor is rejected at structure validation. + /// Tests validate_structure directly because witness verification runs before + /// structure validation in the full pipeline. + #[test] + fn test_zero_anchor_returns_error() { + use dpp::state_transition::StateTransitionStructureValidation; + + let platform_version = PlatformVersion::latest(); + + let mut inputs = BTreeMap::new(); + inputs.insert( + create_platform_address(1), + (1 as AddressNonce, dash_to_credits!(0.5)), + ); + + let transition = ShieldTransitionV0 { + inputs, + actions: vec![create_dummy_serialized_action()], + amount: 1000, + anchor: [0u8; 32], // Zero anchor — invalid + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![create_dummy_witness()], + }; + + let result = transition.validate_structure(platform_version); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedZeroAnchorError(_) + )] + ); + } + + /// AUDIT FIX VERIFICATION: Mutated value_balance is now rejected. + /// + /// Previously, the binding signature was not verified so mutating + /// value_balance from -5000 to -100000 was accepted. Now with + /// BatchValidator, the changed value_balance produces a different + /// bundle commitment (sighash), causing signature verification to fail. + #[test] + fn test_valid_proof_with_mutated_value_balance_is_rejected() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + assert!(value_balance < 0); + let honest_shield_amount = (-value_balance) as u64; + assert_eq!(honest_shield_amount, 5_000); + + // ATTACK: Mutate amount to claim shielding 100,000 instead of 5,000 + let mutated_amount = 100_000u64; + + // Input only provides enough for a small amount, but shield_amount + // comes from amount, not from inputs + let mut inputs = BTreeMap::new(); + inputs.insert( + input_address, + ( + 1 as AddressNonce, + honest_shield_amount + dash_to_credits!(0.01), + ), + ); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions, + amount: mutated_amount, // MUTATED + anchor: anchor_bytes, // Must match the proof's anchor (circuit instance) + proof: proof_bytes, + binding_signature: binding_sig, + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + + let signable_bytes = st.signable_bytes().expect("should compute signable bytes"); + let witnesses: Vec = inputs + .keys() + .map(|address| { + signer + .sign_create_witness(address, &signable_bytes) + .expect("should sign") + }) + .collect(); + + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + + let processing_result = process_transition(&platform, st, platform_version); + + // FIXED: BatchValidator now verifies binding signature and spend auth sigs. + // Mutated value_balance changes the sighash, causing signature verification to fail. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // RETURN PROOF TESTS (prove + verify round-trip) + // ========================================== + + mod return_proof { + use super::*; + use dpp::block::block_info::BlockInfo; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, DashMemo, + Flags as OrchardFlags, FullViewingKey, NoteValue, ProvingKey, Scope, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + /// Extract serialized fields from an authorized Orchard bundle into the + /// platform-compatible format: (actions, flags, value_balance, anchor, proof, binding_sig). + /// Returns `i64` for value_balance (shield bundles have negative value_balance). + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u8, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + + let flags = bundle.flags().to_byte(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + + (actions, flags, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_shield_prove_and_verify_address_balances() { + let platform_version = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut rng = OsRng; + let pk = get_proving_key(); + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + // --- Build valid Orchard bundle (shield = outputs only, no spends) --- + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + let shield_value = 5_000u64; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // Shield sighash extra_data is empty (no transparent output fields) + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + // --- Extract serialized fields from the authorized bundle --- + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be negative for shield (money going into pool) + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + // --- Set up input address with enough balance --- + let mut signer = TestAddressSigner::new(); + let input_address = signer.add_p2pkh([1u8; 32]); + let input_amount = shield_amount + dash_to_credits!(0.01); + setup_address_with_balance(&mut platform, input_address, 0, dash_to_credits!(1.0)); + + // --- Build and sign the shield transition --- + let mut inputs = BTreeMap::new(); + inputs.insert(input_address, (1 as AddressNonce, input_amount)); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions, + amount: shield_amount, + anchor: anchor_bytes, + proof: proof_bytes, + binding_signature: binding_sig, + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + + let signable_bytes = st.signable_bytes().expect("should compute signable bytes"); + let witnesses: Vec = inputs + .keys() + .map(|address| { + signer + .sign_create_witness(address, &signable_bytes) + .expect("should sign") + }) + .collect(); + + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + + // --- Serialize and process with manual transaction so we can commit before proving --- + let transition_bytes = st + .serialize_to_bytes() + .expect("should serialize transition"); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // Commit the transaction so prove_state_transition can read committed state + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // --- Generate proof --- + let proof_result = platform + .drive + .prove_state_transition(&st, None, platform_version) + .expect("expected to generate proof for shield"); + + let proof_bytes = proof_result + .into_data() + .expect("expected proof data, not an error"); + + // --- Verify proof --- + let (root_hash, proof_result) = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("expected to verify shield proof"); + + assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); + + // --- Assert result is VerifiedAddressInfos containing the input address --- + let StateTransitionProofResult::VerifiedAddressInfos(address_infos) = proof_result + else { + panic!("expected VerifiedAddressInfos, got {:?}", proof_result); + }; + + assert!( + address_infos.contains_key(&input_address), + "proof result should contain the input address" + ); + + // The address should have a balance entry (Some) after the shield + let address_info = address_infos + .get(&input_address) + .expect("input address should be in result"); + + assert!( + address_info.is_some(), + "input address should have balance info after shield" + ); + + let (nonce_after, balance_after) = address_info.unwrap(); + + // Nonce should have been incremented + assert_eq!(nonce_after, 1, "nonce should be 1 after first shield"); + + // Balance should be less than original (shield_amount + fees were deducted) + assert!( + balance_after < dash_to_credits!(1.0), + "balance should be less than original after shield" + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..d0ed91f8d78 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs @@ -0,0 +1,71 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::prelude::{AddressNonce, ConsensusValidationResult}; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::shield::ShieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use std::collections::BTreeMap; + +pub(in crate::execution::validation::state_transition::state_transitions::shield) trait ShieldStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let shield_amount: Credits = match self { + ShieldTransition::V0(v0) => v0.amount, + }; + + // Read current shielded pool state from GroveDB + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + // Calculate fees from the GroveDB operations + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + let result = ShieldTransitionAction::try_from_transition( + self, + inputs_with_remaining_balance, + shield_amount, + current_total_balance, + ); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs new file mode 100644 index 00000000000..0d842f23284 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/mod.rs @@ -0,0 +1,71 @@ +mod transform_into_action; + +#[cfg(test)] +mod tests; + +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::shield_from_asset_lock::transform_into_action::v0::ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0; +use crate::execution::validation::state_transition::ValidationMode; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +/// A trait to transform into an action for shield from asset lock transition +pub(in crate::execution) trait StateTransitionShieldFromAssetLockTransitionActionTransformer { + /// Transform into an action for shield from asset lock transition + fn transform_into_action_for_shield_from_asset_lock_transition( + &self, + platform: &PlatformRef, + signable_bytes: Vec, + validation_mode: ValidationMode, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionShieldFromAssetLockTransitionActionTransformer + for ShieldFromAssetLockTransition +{ + fn transform_into_action_for_shield_from_asset_lock_transition( + &self, + platform: &PlatformRef, + signable_bytes: Vec, + validation_mode: ValidationMode, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shield_from_asset_lock_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform, + signable_bytes, + validation_mode, + block_info, + execution_context, + tx, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shield from asset lock transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs new file mode 100644 index 00000000000..bdd23a01373 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs @@ -0,0 +1,913 @@ +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, process_transition, setup_platform, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use assert_matches::assert_matches; + use dpp::consensus::basic::BasicError; + use dpp::consensus::signature::SignatureError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::dash_to_credits; + use dpp::dashcore::{Network, PrivateKey}; + use dpp::identity::KeyType::ECDSA_SECP256K1; + use dpp::platform_value::BinaryData; + use dpp::serialization::{PlatformSerializable, Signable}; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_from_asset_lock_transition::v0::ShieldFromAssetLockTransitionV0; + use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::instant_asset_lock_proof_fixture; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, DashMemo, + Flags as OrchardFlags, FullViewingKey, NoteValue, ProvingKey, Scope, SpendingKey, + }; + use platform_version::version::PlatformVersion; + use rand::prelude::StdRng; + use rand::rngs::OsRng; + use rand::SeedableRng; + use std::sync::OnceLock; + + // ========================================== + // Helper Functions (transition-specific) + // ========================================== + + /// Creates an asset lock proof and returns it with the private key bytes for ECDSA signing. + fn create_asset_lock_proof_with_key( + rng: &mut StdRng, + ) -> ( + dpp::identity::state_transition::asset_lock_proof::AssetLockProof, + Vec, + ) { + let platform_version = PlatformVersion::latest(); + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(rng, platform_version) + .unwrap(); + + let asset_lock_proof = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + None, + ); + + (asset_lock_proof, pk.to_vec()) + } + + /// Build a ShieldFromAssetLock StateTransition with the given fields and ECDSA signature. + /// The `signature` field is computed over the signable bytes using `dashcore::signer::sign`. + fn create_signed_shield_from_asset_lock_transition( + asset_lock_proof: dpp::identity::state_transition::asset_lock_proof::AssetLockProof, + asset_lock_private_key: &[u8], + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + ) -> StateTransition { + // Create unsigned transition to compute signable bytes + let unsigned = ShieldFromAssetLockTransitionV0 { + asset_lock_proof: asset_lock_proof.clone(), + actions: actions.clone(), + value_balance, + anchor, + proof: proof.clone(), + binding_signature, + signature: Default::default(), + }; + + let state_transition: StateTransition = unsigned.into(); + let signable_bytes = state_transition + .signable_bytes() + .expect("should compute signable bytes"); + + // Sign with the asset lock private key (ECDSA) + let signature = + dpp::dashcore::signer::sign(&signable_bytes, asset_lock_private_key).unwrap(); + + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0( + ShieldFromAssetLockTransitionV0 { + asset_lock_proof, + actions, + value_balance, + anchor, + proof, + binding_signature, + signature: BinaryData::new(signature.to_vec()), + }, + )) + } + + /// Build a ShieldFromAssetLock StateTransition with dummy (invalid) signature. + /// Used for structure validation tests where the error is caught before signature check. + fn create_unsigned_shield_from_asset_lock_transition( + asset_lock_proof: dpp::identity::state_transition::asset_lock_proof::AssetLockProof, + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + ) -> StateTransition { + StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0( + ShieldFromAssetLockTransitionV0 { + asset_lock_proof, + actions, + value_balance, + anchor, + proof, + binding_signature, + signature: BinaryData::new(vec![0u8; 65]), // dummy signature + }, + )) + } + + // ========================================== + // Orchard Proving Key (cached, ~30s to build) + // ========================================== + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + /// Extract serialized fields from an authorized Orchard bundle into the + /// platform-compatible format: (actions, flags, value_balance, anchor, proof, binding_sig). + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u8, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + + let flags = bundle.flags().to_byte(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + + (actions, flags, value_balance, anchor, proof, binding_sig) + } + + // ========================================== + // STRUCTURE VALIDATION TESTS (BasicError) + // ========================================== + + mod structure_validation { + use super::*; + + #[test] + fn test_empty_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, _pk) = create_asset_lock_proof_with_key(&mut rng); + + let transition = create_unsigned_shield_from_asset_lock_transition( + asset_lock_proof, + vec![], // Empty actions -- invalid + 1000, + [0u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedNoActionsError(_)) + )] + ); + } + + /// Tests validate_structure directly because 101 actions exceed the + /// max_state_transition_size (20KB) before reaching the actions count check + /// in the full pipeline. + #[test] + fn test_too_many_actions_returns_error() { + use dpp::state_transition::StateTransitionStructureValidation; + + let platform_version = PlatformVersion::latest(); + + // 101 actions exceeds max_shielded_transition_actions (100) + let actions: Vec = + (0..101).map(|_| create_dummy_serialized_action()).collect(); + + let transition = ShieldFromAssetLockTransitionV0 { + asset_lock_proof: instant_asset_lock_proof_fixture(None, None), + actions, + value_balance: 1000, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + signature: Default::default(), + }; + + let result = transition.validate_structure(platform_version); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedTooManyActionsError(_) + )] + ); + } + + #[test] + fn test_zero_anchor_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(571); + let (asset_lock_proof, _pk) = create_asset_lock_proof_with_key(&mut rng); + + let transition = create_unsigned_shield_from_asset_lock_transition( + asset_lock_proof, + vec![create_dummy_serialized_action()], + 1000, + [0u8; 32], // Zero anchor — invalid + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedZeroAnchorError(_)) + )] + ); + } + + #[test] + fn test_zero_value_balance_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(569); + let (asset_lock_proof, _pk) = create_asset_lock_proof_with_key(&mut rng); + + let transition = create_unsigned_shield_from_asset_lock_transition( + asset_lock_proof, + vec![create_dummy_serialized_action()], + 0, // Zero -- invalid for shielding + [0u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(570); + let (asset_lock_proof, _pk) = create_asset_lock_proof_with_key(&mut rng); + + let transition = create_unsigned_shield_from_asset_lock_transition( + asset_lock_proof, + vec![create_dummy_serialized_action()], + 1000, + [0u8; 32], + vec![], // Empty proof -- invalid + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedEmptyProofError(_)) + )] + ); + } + } + + // ========================================== + // ASSET LOCK VALIDATION TESTS + // ========================================== + + mod asset_lock_validation { + use super::*; + + /// Happy path for asset lock validation: valid instant asset lock with properly signed + /// ECDSA signature, but dummy ZK proof data. The transition should pass structure + /// validation, asset lock validation, and ECDSA signature verification, then fail + /// at ZK proof verification (producing a PaidConsensusError with penalty action). + #[test] + fn test_valid_instant_asset_lock_creates_action() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // Use a shield amount much smaller than the asset lock value (1 Dash = 100_000_000 duffs) + let shield_amount = 5000u64; + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + vec![create_dummy_serialized_action()], + shield_amount, + [42u8; 32], // non-zero anchor (won't match any stored anchor, but proof check is first) + vec![0u8; 100], // dummy proof bytes + [0u8; 64], // dummy binding signature + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Asset lock validation + ECDSA sig passed, but ZK proof failed. + // This produces a PaidConsensusError (the asset lock is partially consumed as penalty). + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } + } + + // ========================================== + // SIGNATURE VALIDATION TESTS (SignatureError) + // ========================================== + + mod signature_validation { + use super::*; + + #[test] + fn test_wrong_ecdsa_signature_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, _asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // Build transition with a completely zeroed (invalid) signature + let transition = StateTransition::ShieldFromAssetLock( + ShieldFromAssetLockTransition::V0(ShieldFromAssetLockTransitionV0 { + asset_lock_proof, + actions: vec![create_dummy_serialized_action()], + value_balance: 5000, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + signature: BinaryData::new(vec![0u8; 65]), // zeroed invalid signature + }), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // ECDSA verification fails because the signature does not match the public key + // derived from the asset lock output. This is caught after asset lock validation + // but before ZK proof verification. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(SignatureError::BasicECDSAError(_)) + )] + ); + } + + #[test] + fn test_signature_from_different_key_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, _correct_pk) = create_asset_lock_proof_with_key(&mut rng); + + // Generate a different key pair to sign with (wrong key) + let wrong_private_key = PrivateKey::from_byte_array( + &[42u8; 32], // arbitrary seed that is different + Network::Testnet, + ) + .unwrap(); + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &wrong_private_key.inner.secret_bytes(), // Wrong key + vec![create_dummy_serialized_action()], + 5000, + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(SignatureError::BasicECDSAError(_)) + )] + ); + } + } + + // ========================================== + // ZK PROOF VERIFICATION TESTS + // ========================================== + + mod proof_verification { + use super::*; + + /// End-to-end test: valid instant asset lock + valid Orchard bundle = success. + /// + /// This test builds a real Orchard bundle with ProvingKey (~30s on first run, + /// cached via OnceLock for subsequent tests). + #[test] + fn test_valid_proof_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // Build a valid Orchard bundle (shield = outputs only) + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + let shield_value = 5000u64; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // No extra_sighash_data for shield_from_asset_lock (empty, like shield) + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be negative for shield (money going into pool) + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + /// Test that a structurally valid transition with dummy ZK proof data is rejected + /// with a PaidConsensusError (penalty applied via PartiallyUseAssetLockAction). + #[test] + fn test_invalid_proof_returns_shielded_proof_error_with_penalty() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + vec![create_dummy_serialized_action()], + 5000, + [42u8; 32], + vec![0u8; 100], // random proof data + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Passes structure + asset lock + ECDSA, fails at ZK proof verification. + // The asset lock is partially consumed as penalty. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } + } + + // ========================================== + // SECURITY AUDIT TESTS + // ========================================== + + mod security_audit { + use super::*; + + #[test] + fn test_value_balance_exceeding_i64_max_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(572); + let (asset_lock_proof, _pk) = create_asset_lock_proof_with_key(&mut rng); + + let transition = create_unsigned_shield_from_asset_lock_transition( + asset_lock_proof, + vec![create_dummy_serialized_action()], + i64::MAX as u64 + 1, // Exceeds i64::MAX + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + /// AUDIT FIX VERIFICATION: Mutated value_balance is rejected by BatchValidator. + /// + /// This test builds a valid Orchard bundle with value_balance = -5000, then + /// mutates value_balance to -100000 in the transition. The binding signature + /// no longer matches, causing proof verification to fail. + #[test] + fn test_valid_proof_with_mutated_value_balance_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // Build a valid Orchard bundle + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + assert!(value_balance < 0); + let honest_shield_amount = (-value_balance) as u64; + assert_eq!(honest_shield_amount, 5_000); + + // ATTACK: Mutate value_balance to claim shielding 100,000 instead of 5,000 + let mutated_value_balance = 100_000u64; + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + mutated_value_balance, // MUTATED + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // BatchValidator now verifies binding signature. Mutated value_balance + // changes the bundle commitment / sighash, causing verification to fail. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } + + /// Verify that zeroed spend_auth_sig values (all zeros) in actions are rejected. + #[test] + fn test_zeroed_signatures_in_actions_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // Build a valid Orchard bundle first, then zero out the spend_auth_sig + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (mut actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // Zero out all spend_auth_sig values + for action in actions.iter_mut() { + action.spend_auth_sig = [0u8; 64]; + } + + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Zeroed spend_auth_sig causes BatchValidator to reject the bundle + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } + } + + // ========================================== + // RETURN PROOF TESTS + // ========================================== + + mod return_proof { + use super::*; + use dpp::asset_lock::StoredAssetLockInfo; + use dpp::block::block_info::BlockInfo; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, DashMemo, + Flags as OrchardFlags, FullViewingKey, NoteValue, ProvingKey, Scope, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u8, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + + let flags = bundle.flags().to_byte(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + + (actions, flags, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_shield_from_asset_lock_prove_and_verify() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let mut rng = StdRng::seed_from_u64(567); + let (asset_lock_proof, asset_lock_pk) = create_asset_lock_proof_with_key(&mut rng); + + // --- Build a valid Orchard bundle (shield = outputs only) --- + let mut orchard_rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let anchor = Anchor::empty_tree(); + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + anchor, + ); + + let shield_value = 5_000u64; + builder + .add_output( + None, + recipient, + NoteValue::from_raw(shield_value), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut orchard_rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut orchard_rng).unwrap(); + let bundle = proven.apply_signatures(orchard_rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + assert!(value_balance < 0); + let shield_amount = (-value_balance) as u64; + + // --- Build and sign the transition --- + let transition = create_signed_shield_from_asset_lock_transition( + asset_lock_proof, + &asset_lock_pk, + actions, + shield_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + // --- Serialize and process with manual transaction so we can commit before proving --- + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // Commit the transaction so prove_state_transition can read committed state + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // --- Generate proof --- + let proof_result = platform + .drive + .prove_state_transition(&transition, None, platform_version) + .expect("expected to generate proof for shield_from_asset_lock"); + + let proof_bytes = proof_result + .into_data() + .expect("expected proof data, not an error"); + + // --- Verify proof --- + let (root_hash, proof_result) = Drive::verify_state_transition_was_executed_with_proof( + &transition, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("expected to verify shield_from_asset_lock proof"); + + assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); + + // --- Assert result is VerifiedAssetLockConsumed --- + let StateTransitionProofResult::VerifiedAssetLockConsumed(info) = proof_result else { + panic!("expected VerifiedAssetLockConsumed, got {:?}", proof_result); + }; + + // ShieldFromAssetLock always fully consumes the asset lock + // (remaining_credit_value is set to 0 in action-to-operations conversion). + assert!( + matches!(info, StoredAssetLockInfo::FullyConsumed), + "expected FullyConsumed, got {:?}", + info + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/mod.rs new file mode 100644 index 00000000000..e88e88c052f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(in crate::execution::validation::state_transition::state_transitions::shield_from_asset_lock) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..64bcb80c0dc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/transform_into_action/v0/mod.rs @@ -0,0 +1,319 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::signature_verification_operation::SignatureVerificationOperation; +use crate::execution::types::execution_operation::{ValidationOperation, SHA256_BLOCK_SIZE}; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::asset_lock::proof::validate::AssetLockProofValidation; +use crate::execution::validation::state_transition::common::asset_lock::transaction::fetch_asset_lock_transaction_output_sync::fetch_asset_lock_transaction_output_sync; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, reconstruct_and_verify_bundle, FLAGS_OUTPUTS_ONLY, +}; +use crate::execution::validation::state_transition::ValidationMode; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; +use dpp::asset_lock::reduced_asset_lock_value::{AssetLockValue, AssetLockValueGettersV0}; +use dpp::block::block_info::BlockInfo; +use dpp::balances::credits::CREDITS_PER_DUFF; +use dpp::consensus::basic::identity::IdentityAssetLockTransactionOutPointNotEnoughBalanceError; +use dpp::consensus::signature::{BasicECDSAError, SignatureError}; +use dpp::consensus::state::state_error::StateError; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::{signer, ScriptBuf, Txid}; +use dpp::fee::Credits; +use dpp::identity::state_transition::AssetLockProved; +use dpp::identity::KeyType; +use dpp::platform_value::{Bytes32, Bytes36}; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; +use dpp::state_transition::signable_bytes_hasher::SignableBytesHasher; +use dpp::state_transition::{StateTransitionEstimatedFeeValidation, StateTransitionSingleSigned}; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; +use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockActionV0; +use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::shield_from_asset_lock) trait ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + platform: &PlatformRef, + signable_bytes: Vec, + validation_mode: ValidationMode, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl ShieldFromAssetLockStateTransitionTransformIntoActionValidationV0 + for ShieldFromAssetLockTransition +{ + fn transform_into_action_v0( + &self, + platform: &PlatformRef, + signable_bytes: Vec, + validation_mode: ValidationMode, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + // Step 1: Get the shield amount (value_balance is u64, the amount entering the pool) + let shield_amount: Credits = match self { + ShieldFromAssetLockTransition::V0(v0) => v0.value_balance, + }; + + // Step 3: Calculate minimum required fee from platform_version + let required_balance = self.calculate_min_required_fee(platform_version)?; + + let signable_bytes_len = signable_bytes.len(); + + let mut signable_bytes_hasher = SignableBytesHasher::Bytes(signable_bytes); + + // Step 4: Validate asset lock proof + let asset_lock_proof_validation = if validation_mode != ValidationMode::NoValidation { + AssetLockProved::asset_lock_proof(self).validate( + platform, + &mut signable_bytes_hasher, + required_balance, + validation_mode, + tx, + platform_version, + )? + } else { + ConsensusValidationResult::new() + }; + + if !asset_lock_proof_validation.is_valid() { + return Ok(ConsensusValidationResult::new_with_errors( + asset_lock_proof_validation.errors, + )); + } + + // Step 5: Fetch/validate asset lock transaction output + let mut needs_signature_verification = true; + + let asset_lock_value_to_be_consumed = if asset_lock_proof_validation.has_data() { + let asset_lock_value = asset_lock_proof_validation.into_data()?; + // There is no need to recheck signatures on recheck tx + if validation_mode == ValidationMode::RecheckTx { + needs_signature_verification = false; + } + asset_lock_value + } else { + let tx_out_validation = fetch_asset_lock_transaction_output_sync( + platform.core_rpc, + AssetLockProved::asset_lock_proof(self), + platform_version, + )?; + + if !tx_out_validation.is_valid() { + return Ok(ConsensusValidationResult::new_with_errors( + tx_out_validation.errors, + )); + } + + let tx_out = tx_out_validation.into_data()?; + + let tx_out_credit_value = tx_out.value.saturating_mul(CREDITS_PER_DUFF); + + // Verify locked amount >= shield_amount + min_fee + let required_total = shield_amount.saturating_add(required_balance); + if tx_out_credit_value < required_total { + let asset_lock_proof = AssetLockProved::asset_lock_proof(self); + return Ok(ConsensusValidationResult::new_with_error( + IdentityAssetLockTransactionOutPointNotEnoughBalanceError::new( + asset_lock_proof + .out_point() + .map(|outpoint| outpoint.txid) + .unwrap_or(Txid::all_zeros()), + asset_lock_proof.output_index() as usize, + tx_out_credit_value, + tx_out_credit_value, + required_total, + ) + .into(), + )); + } + + if validation_mode == ValidationMode::RecheckTx { + needs_signature_verification = false; + } + + let initial_balance_amount = tx_out.value.saturating_mul(CREDITS_PER_DUFF); + AssetLockValue::new( + initial_balance_amount, + tx_out.script_pubkey.0, + initial_balance_amount, + vec![], + platform_version, + )? + }; + + // Step 6: Verify ECDSA signature over signable_bytes (P2PKH from asset lock output) + if needs_signature_verification { + let tx_out_script_pubkey = + ScriptBuf(asset_lock_value_to_be_consumed.tx_out_script().clone()); + + let public_key_hash = tx_out_script_pubkey + .p2pkh_public_key_hash_bytes() + .ok_or_else(|| { + Error::Execution(ExecutionError::CorruptedCachedState( + "the script inside the state must be a p2pkh".to_string(), + )) + })?; + + let block_count = + (signable_bytes_len / SHA256_BLOCK_SIZE as usize).min(u16::MAX as usize) as u16; + + execution_context.add_operation(ValidationOperation::DoubleSha256(block_count)); + execution_context.add_operation(ValidationOperation::SignatureVerification( + SignatureVerificationOperation::new(KeyType::ECDSA_HASH160), + )); + + if let Err(e) = signer::verify_hash_signature( + signable_bytes_hasher.hash_bytes().as_slice(), + self.signature().as_slice(), + public_key_hash, + ) { + return Ok(ConsensusValidationResult::new_with_error( + SignatureError::BasicECDSAError(BasicECDSAError::new(e.to_string())).into(), + )); + } + } + + // Step 7: Also check that the remaining asset lock balance covers shield_amount + let remaining_credit_value = asset_lock_value_to_be_consumed.remaining_credit_value(); + if remaining_credit_value < shield_amount { + let asset_lock_proof = AssetLockProved::asset_lock_proof(self); + return Ok(ConsensusValidationResult::new_with_error( + IdentityAssetLockTransactionOutPointNotEnoughBalanceError::new( + asset_lock_proof + .out_point() + .map(|outpoint| outpoint.txid) + .unwrap_or(Txid::all_zeros()), + asset_lock_proof.output_index() as usize, + remaining_credit_value, + remaining_credit_value, + shield_amount, + ) + .into(), + )); + } + + // Step 8: Read current shielded pool total balance from GroveDB + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(platform.drive, tx, &mut drive_operations, platform_version)?; + + // Calculate fees from the GroveDB operations + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + platform.drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + // Step 9: Verify Orchard ZK proof via reconstruct_and_verify_bundle() + // Use EMPTY extra_sighash_data -- no transparent binding needed since + // the asset lock proof authenticates the source of funds. + let (actions, anchor, proof, binding_signature) = match self { + ShieldFromAssetLockTransition::V0(v0) => ( + &v0.actions, + &v0.anchor, + v0.proof.as_slice(), + &v0.binding_signature, + ), + }; + + if let Err(e) = reconstruct_and_verify_bundle( + actions, + FLAGS_OUTPUTS_ONLY, + -(shield_amount as i64), + anchor, + proof, + binding_signature, + &[], // No transparent fields to bind for shield_from_asset_lock + ) { + // Step 10: ZK proof failed -- consume asset lock with penalty (PartiallyUseAssetLockAction) + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + + let desired_used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(Error::Execution(ExecutionError::Overflow( + "processing fee overflow in shield_from_asset_lock penalty calculation", + )))?; + + let asset_lock_outpoint = AssetLockProved::asset_lock_proof(self) + .out_point() + .ok_or_else(|| { + Error::Execution(ExecutionError::CorruptedCachedState( + "asset lock proof must have an outpoint after validation".to_string(), + )) + })?; + + let signable_bytes_hash: Bytes32 = signable_bytes_hasher.into_hashed_bytes(); + let mut previous_transaction_hashes = + asset_lock_value_to_be_consumed.used_tags_ref().clone(); + previous_transaction_hashes.push(signable_bytes_hash); + + let remaining_after_penalty = + remaining_credit_value.saturating_sub(desired_used_credits); + let used_credits = std::cmp::min(remaining_credit_value, desired_used_credits); + + let partially_use_action = + PartiallyUseAssetLockAction::from(PartiallyUseAssetLockActionV0 { + asset_lock_outpoint: Bytes36::new(asset_lock_outpoint.into()), + initial_credit_value: asset_lock_value_to_be_consumed.initial_credit_value(), + previous_transaction_hashes, + asset_lock_script: asset_lock_value_to_be_consumed.tx_out_script().clone(), + remaining_credit_value: remaining_after_penalty, + used_credits, + user_fee_increase: 0, + inputs_with_remaining_balance: None, + fee_strategy: None, + }); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::PartiallyUseAssetLockAction(partially_use_action), + vec![StateError::InvalidShieldedProofError(e).into()], + )); + } + + // Step 11: Build the successful action + let asset_lock_outpoint = AssetLockProved::asset_lock_proof(self) + .out_point() + .ok_or_else(|| { + Error::Execution(ExecutionError::CorruptedCachedState( + "asset lock proof must have an outpoint after validation".to_string(), + )) + })?; + + let asset_lock_value_credits = asset_lock_value_to_be_consumed.remaining_credit_value(); + let signable_bytes_hash: [u8; 32] = signable_bytes_hasher.into_hashed_bytes().0; + + let result = ShieldFromAssetLockTransitionAction::try_from_transition( + self, + asset_lock_outpoint.into(), + asset_lock_value_credits, + signable_bytes_hash, + shield_amount, + current_total_balance, + ); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_common/mod.rs new file mode 100644 index 00000000000..9e762a80368 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_common/mod.rs @@ -0,0 +1,296 @@ +use crate::error::Error; +use dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; +use dpp::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; +use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; +use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; +use dpp::consensus::state::state_error::StateError; +use dpp::fee::Credits; +use dpp::prelude::ConsensusValidationResult; +pub use dpp::shielded::compute_platform_sighash; +use dpp::shielded::SerializedAction; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::fees::op::LowLevelDriveOperation; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; +use grovedb_commitment_tree::{ + redpallas, Action, Anchor, Authorized, BatchValidator, Bundle, DashMemo, + ExtractedNoteCommitment, Flags, NoteBytesData, Nullifier, Proof, TransmittedNoteCiphertext, + ValueCommitment, VerifyingKey, +}; +use std::sync::OnceLock; + +/// Orchard bundle flags byte: only outputs are real (spends are dummy). +/// Used for shield and shield-from-asset-lock transitions where funds enter the pool. +pub const FLAGS_OUTPUTS_ONLY: u8 = 0x02; + +/// Orchard bundle flags byte: both spends and outputs are real. +/// Used for shielded transfers, unshield, and shielded-withdrawal transitions. +pub const FLAGS_SPENDS_AND_OUTPUTS: u8 = 0x03; + +/// Cached verifying key for shielded proof verification. +/// +/// The key is deterministic (same circuit → same key) and immutable. +/// Building it takes ~5s, so it's lazily initialized on first use. +static SHIELDED_VERIFYING_KEY: OnceLock = OnceLock::new(); + +fn get_verifying_key() -> &'static VerifyingKey { + SHIELDED_VERIFYING_KEY.get_or_init(VerifyingKey::build) +} + +/// Pre-builds the shielded verifying key so that the first shielded +/// transaction does not pay the ~5-15 s construction cost at check_tx time. +pub fn warmup_shielded_verifying_key() { + get_verifying_key(); +} + +const EPK_SIZE: usize = 32; +const ENC_CIPHERTEXT_SIZE: usize = 104; +const OUT_CIPHERTEXT_SIZE: usize = 80; +const ENCRYPTED_NOTE_SIZE: usize = EPK_SIZE + ENC_CIPHERTEXT_SIZE + OUT_CIPHERTEXT_SIZE; // 216 + +/// Reconstructs an orchard `Bundle` from the serialized fields +/// of a shielded state transition and verifies the Halo 2 ZK proof along with +/// all RedPallas signatures (spend auth + binding). +/// +/// Uses `BatchValidator` which verifies: +/// 1. The Halo 2 circuit proof (zero-knowledge proof of spend validity) +/// 2. Spend authorization signatures (proves the spender controls the spending key) +/// 3. The binding signature (binds value_balance to value commitments, preventing manipulation) +/// +/// The sighash is computed via `compute_platform_sighash()`, which hashes the +/// Orchard bundle commitment together with `extra_sighash_data` (transparent fields). +/// The same computation must be used when signing the bundle on the client side. +/// +/// `extra_sighash_data` binds transparent fields to the Orchard signatures: +/// - Shield: empty (no transparent outputs) +/// - Shielded transfer: empty (no transparent fields) +/// - Unshield: `output_address.to_bytes() || amount.to_le_bytes()` +/// +/// Returns `Ok(())` if all verification passes, or an `InvalidShieldedProofError` +/// if reconstruction or any verification step fails. +pub fn reconstruct_and_verify_bundle( + actions: &[SerializedAction], + flags: u8, + value_balance: i64, + anchor: &[u8; 32], + proof: &[u8], + binding_signature: &[u8; 64], + extra_sighash_data: &[u8], +) -> Result<(), InvalidShieldedProofError> { + let vk = get_verifying_key(); + + // Reconstruct each Action + let mut orchard_actions = Vec::with_capacity(actions.len()); + for a in actions { + // Parse encrypted_note (216 bytes = epk 32 + enc 104 + out 80) + if a.encrypted_note.len() != ENCRYPTED_NOTE_SIZE { + return Err(InvalidShieldedProofError::new(format!( + "encrypted note size mismatch: expected {ENCRYPTED_NOTE_SIZE}, got {}", + a.encrypted_note.len() + ))); + } + let epk_bytes: [u8; 32] = a.encrypted_note[..EPK_SIZE] + .try_into() + .expect("length verified to be ENCRYPTED_NOTE_SIZE"); + let enc_ciphertext: [u8; ENC_CIPHERTEXT_SIZE] = a.encrypted_note + [EPK_SIZE..EPK_SIZE + ENC_CIPHERTEXT_SIZE] + .try_into() + .expect("length verified to be ENCRYPTED_NOTE_SIZE"); + let out_ciphertext: [u8; OUT_CIPHERTEXT_SIZE] = a.encrypted_note + [EPK_SIZE + ENC_CIPHERTEXT_SIZE..] + .try_into() + .expect("length verified to be ENCRYPTED_NOTE_SIZE"); + + let nullifier: Nullifier = Option::from(Nullifier::from_bytes(&a.nullifier)) + .ok_or_else(|| InvalidShieldedProofError::new("invalid nullifier bytes".to_string()))?; + + let rk = redpallas::VerificationKey::try_from(a.rk).map_err(|e| { + InvalidShieldedProofError::new(format!("invalid spend validating key: {e}")) + })?; + + let cmx: ExtractedNoteCommitment = + Option::from(ExtractedNoteCommitment::from_bytes(&a.cmx)).ok_or_else(|| { + InvalidShieldedProofError::new("invalid note commitment bytes".to_string()) + })?; + + let cv_net: ValueCommitment = Option::from(ValueCommitment::from_bytes(&a.cv_net)) + .ok_or_else(|| { + InvalidShieldedProofError::new("invalid value commitment bytes".to_string()) + })?; + + let action = Action::from_parts( + nullifier, + rk, + cmx, + TransmittedNoteCiphertext::::from_parts( + epk_bytes, + NoteBytesData(enc_ciphertext), + out_ciphertext, + ), + cv_net, + redpallas::Signature::from(a.spend_auth_sig), + ); + orchard_actions.push(action); + } + + // Reconstruct Authorized (proof + binding signature) + let authorized = Authorized::from_parts( + Proof::new(proof.to_vec()), + redpallas::Signature::from(*binding_signature), + ); + + // Reconstruct Bundle + let orchard_flags = Flags::from_byte(flags).ok_or_else(|| { + InvalidShieldedProofError::new(format!("invalid bundle flags byte: {flags:#04x}")) + })?; + + let orchard_anchor = Option::from(Anchor::from_bytes(*anchor)) + .ok_or_else(|| InvalidShieldedProofError::new("invalid anchor bytes".to_string()))?; + + let actions_nonempty = nonempty::NonEmpty::from_vec(orchard_actions) + .ok_or_else(|| InvalidShieldedProofError::new("bundle has no actions".to_string()))?; + + let bundle = Bundle::from_parts( + actions_nonempty, + orchard_flags, + value_balance, + orchard_anchor, + authorized, + ); + + // Compute the platform sighash: SHA-256(domain || bundle_commitment || extra_data). + // The bundle commitment is the Orchard BundleCommitment (BLAKE2b-256 per ZIP-244), + // covering: flags, value_balance, anchor, and all action fields + // (nullifier, rk, cmx, cv_net, encrypted_note) — but NOT the signatures or proof. + // The extra_sighash_data binds transparent fields (e.g., output_address for unshield). + let bundle_commitment: [u8; 32] = bundle.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, extra_sighash_data); + + // Verify the Halo 2 proof AND all RedPallas signatures (spend auth + binding) + // using BatchValidator. This is the correct Orchard verification flow, ensuring: + // - The ZK circuit proof is valid + // - Each spend auth signature is valid for (rk, sighash) + // - The binding signature is valid for (binding_validating_key, sighash) + let mut batch = BatchValidator::new(); + batch.add_bundle(&bundle, sighash); + + let rng = rand::rngs::OsRng; + if !batch.validate(vk, rng) { + return Err(InvalidShieldedProofError::new( + "bundle verification failed: proof, spend auth signatures, or binding signature invalid" + .to_string(), + )); + } + + Ok(()) +} + +/// Read the current shielded pool total balance from GroveDB. +/// Returns 0 if the balance key doesn't exist yet. +/// +/// Delegates to `Drive::read_shielded_pool_total_balance`. +pub fn read_pool_total_balance( + drive: &Drive, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, +) -> Result { + drive + .read_shielded_pool_total_balance(transaction, drive_operations, platform_version) + .map_err(Error::Drive) +} + +/// Verify that the anchor exists in the recorded anchors tree. +/// Uses O(1) key lookup instead of scanning the entire tree. +/// Returns a consensus error if the anchor is not found. +/// +/// Delegates to `Drive::has_shielded_anchor` for the GroveDB lookup. +pub fn validate_anchor_exists( + drive: &Drive, + anchor: &[u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, +) -> Result>, Error> { + let found = drive + .has_shielded_anchor(anchor, transaction, drive_operations, platform_version) + .map_err(Error::Drive)?; + + if !found { + Ok(Some(ConsensusValidationResult::new_with_error( + StateError::InvalidAnchorError(InvalidAnchorError::new(*anchor)).into(), + ))) + } else { + Ok(None) + } +} + +/// Defense-in-depth: reject duplicate nullifiers within the same bundle, +/// then check that no nullifier has already been spent in state. +/// +/// Phase 1 (intra-bundle HashSet check) stays here. +/// Phase 2 delegates to `Drive::has_nullifier` for each GroveDB lookup. +pub fn validate_nullifiers( + drive: &Drive, + nullifiers: &[[u8; 32]], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, +) -> Result>, Error> { + // Phase 1: Intra-bundle duplicate check (no GroveDB access) + let mut seen_nullifiers = std::collections::HashSet::new(); + for nullifier in nullifiers { + if !seen_nullifiers.insert(nullifier) { + return Ok(Some(ConsensusValidationResult::new_with_error( + StateError::NullifierAlreadySpentError(NullifierAlreadySpentError::new(*nullifier)) + .into(), + ))); + } + } + // Phase 2: Check against state via Drive method + for nullifier in nullifiers { + let exists = drive + .has_nullifier(nullifier, transaction, drive_operations, platform_version) + .map_err(Error::Drive)?; + if exists { + return Ok(Some(ConsensusValidationResult::new_with_error( + StateError::NullifierAlreadySpentError(NullifierAlreadySpentError::new(*nullifier)) + .into(), + ))); + } + } + Ok(None) +} + +/// Check minimum notes threshold for outgoing transitions (anonymity set). +/// +/// Delegates to `Drive::shielded_pool_notes_count` for the GroveDB lookup. +/// The threshold check and consensus error wrapping stay here. +pub fn validate_minimum_pool_notes( + drive: &Drive, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, +) -> Result>, Error> { + let min_notes = platform_version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing; + if min_notes > 0 { + let encrypted_notes_count = drive + .shielded_pool_notes_count(transaction, drive_operations, platform_version) + .map_err(Error::Drive)?; + if encrypted_notes_count < min_notes { + return Ok(Some(ConsensusValidationResult::new_with_error( + StateError::InsufficientPoolNotesError(InsufficientPoolNotesError::new( + encrypted_notes_count, + min_notes, + )) + .into(), + ))); + } + } + Ok(None) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/mod.rs new file mode 100644 index 00000000000..bf0ce41200a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/mod.rs @@ -0,0 +1,64 @@ +mod transform_into_action; + +#[cfg(test)] +mod tests; + +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::shielded_transfer::transform_into_action::v0::ShieldedTransferStateTransitionTransformIntoActionValidationV0; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use crate::platform_types::platform_state::PlatformStateV0Methods; + +/// A trait to transform into an action for shielded transfer transition +pub trait StateTransitionShieldedTransferTransitionActionTransformer { + /// Transform into an action for shielded transfer transition + fn transform_into_action_for_shielded_transfer_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionShieldedTransferTransitionActionTransformer for ShieldedTransferTransition { + fn transform_into_action_for_shielded_transfer_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shielded_transfer_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform.drive, + tx, + block_info, + execution_context, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shielded transfer transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs new file mode 100644 index 00000000000..ca43b82e569 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs @@ -0,0 +1,1225 @@ +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, insert_anchor_into_state, insert_nullifier_into_state, + process_transition, set_pool_total_balance, setup_platform, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use assert_matches::assert_matches; + use dpp::consensus::basic::BasicError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; + use dpp::state_transition::StateTransition; + use platform_version::version::PlatformVersion; + + // ========================================== + // Helper Functions (transition-specific) + // ========================================== + + /// Builds a `ShieldedTransferTransition` state transition. + /// No signing needed since shielded transfers have no witnesses. + fn create_shielded_transfer_transition( + actions: Vec, + value_balance: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + ) -> StateTransition { + StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0( + ShieldedTransferTransitionV0 { + actions, + value_balance, + anchor, + proof, + binding_signature, + }, + )) + } + + /// Shorthand for creating a structurally valid (but cryptographically invalid) shielded + /// transfer transition. Has a non-zero anchor, valid field sizes, but random data. + /// Includes sufficient fee to pass the minimum shielded fee check (1 action = 111,548,800). + fn create_default_shielded_transfer_transition() -> StateTransition { + create_shielded_transfer_transition( + vec![create_dummy_serialized_action()], + 111_548_800, // minimum fee for 1 action + [42u8; 32], // non-zero anchor + vec![0u8; 100], // dummy proof bytes + [0u8; 64], // dummy binding signature + ) + } + + // ========================================== + // STRUCTURE VALIDATION TESTS (BasicError) + // ========================================== + + mod structure_validation { + use super::*; + + #[test] + fn test_empty_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_transfer_transition( + vec![], // Empty actions — invalid + 0, + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedNoActionsError(_)) + )] + ); + } + + /// Tests validate_structure directly because 101 actions exceed the + /// max_state_transition_size (20KB) before reaching the actions count check + /// in the full pipeline. + #[test] + fn test_too_many_actions_returns_error() { + use dpp::state_transition::StateTransitionStructureValidation; + + let platform_version = PlatformVersion::latest(); + + // 101 actions exceeds max_shielded_transition_actions (100) + let actions: Vec = + (0..101).map(|_| create_dummy_serialized_action()).collect(); + + let transition = ShieldedTransferTransitionV0 { + actions, + value_balance: 111_548_800, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + }; + + let result = transition.validate_structure(platform_version); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedTooManyActionsError(_) + )] + ); + } + + #[test] + fn test_value_balance_exceeding_i64_max_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_transfer_transition( + vec![create_dummy_serialized_action()], + i64::MAX as u64 + 1, // Exceeds i64::MAX — invalid + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_transfer_transition( + vec![create_dummy_serialized_action()], + 1, // non-zero so we don't hit value_balance == 0 rejection first + [42u8; 32], + vec![], // Empty proof — invalid + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedEmptyProofError(_)) + )] + ); + } + + #[test] + fn test_zero_anchor_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_transfer_transition( + vec![create_dummy_serialized_action()], + 1, // non-zero so we don't hit value_balance == 0 rejection first + [0u8; 32], // All zeros — invalid + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedZeroAnchorError(_)) + )] + ); + } + } + + // ========================================== + // ANCHOR VALIDATION TESTS (StateError) + // ========================================== + + mod anchor_validation { + use super::*; + + #[test] + fn test_invalid_anchor_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Non-zero anchor that doesn't exist in state + let transition = create_default_shielded_transfer_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before anchor validation, so the + // dummy proof data is rejected before the anchor check is reached. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // NULLIFIER DOUBLE-SPEND TESTS (StateError) + // ========================================== + + mod nullifier_validation { + use super::*; + + #[test] + fn test_nullifier_already_spent_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let anchor = [42u8; 32]; + let nullifier = [1u8; 32]; // Same as create_dummy_serialized_action().nullifier + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Insert the nullifier so it appears already spent + insert_nullifier_into_state(&platform, &nullifier); + + let transition = create_default_shielded_transfer_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before nullifier validation, so the + // dummy proof data is rejected before the nullifier check is reached. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // ZK PROOF VERIFICATION TESTS (InvalidShieldedProofError) + // ========================================== + + mod proof_verification { + use super::*; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, + MerklePath, Note, NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance() as u64; + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_invalid_proof_returns_shielded_proof_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let anchor = [42u8; 32]; + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // This transition is structurally valid and has a valid anchor, + // but has random ZK proof data. It should pass structure validation + // and anchor validation but fail at proof verification. + let transition = create_default_shielded_transfer_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// Minimum fee for 2 actions (Orchard builder always produces ≥2). + const MINIMUM_FEE_2_ACTIONS: u64 = 123_097_600; + + #[test] + fn test_valid_shielded_transfer_proof_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let mut rng = OsRng; + let pk = get_proving_key(); + + let spend_amount = 200_000_000u64; + let output_amount = spend_amount - MINIMUM_FEE_2_ACTIONS; + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; // Non-zero valid Pallas field element + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(spend_amount), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle: spend 200M → output (200M - fee), value_balance = fee --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(output_amount), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + // --- Extract serialized fields --- + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + assert_eq!(value_balance, MINIMUM_FEE_2_ACTIONS); + + // --- Set pool balance and insert anchor --- + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + // --- Create and process transition --- + let transition = create_shielded_transfer_transition( + actions, + value_balance, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[test] + fn test_wrong_encrypted_note_size_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let anchor = [42u8; 32]; + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Create action with wrong encrypted_note size + let mut bad_action = create_dummy_serialized_action(); + bad_action.encrypted_note = vec![0u8; 100]; // 100 bytes instead of 216 + + let transition = create_shielded_transfer_transition( + vec![bad_action], + 111_548_800, // minimum fee for 1 action (fee check runs before proof reconstruction) + anchor, + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // FEE VALIDATION TESTS (InsufficientShieldedFeeError) + // ========================================== + // + // The minimum shielded fee is: + // min_fee = proof_verification_fee + num_actions × (processing_fee + storage_fee) + // + // With current constants: + // proof_verification_fee = 100_000_000 + // per_action_processing_fee = 3_000_000 + // per_action_storage_fee = 312 × (27_000 + 400) = 8_548_800 + // per_action_total = 11_548_800 + // + // Minimum fees by action count: + // 2 actions: 100_000_000 + 2 × 11_548_800 = 123_097_600 + // 3 actions: 100_000_000 + 3 × 11_548_800 = 134_646_400 + // 4 actions: 100_000_000 + 4 × 11_548_800 = 146_195_200 + + mod fee_validation { + use super::*; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, + MerklePath, Note, NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + const MINIMUM_FEE_2_ACTIONS: u64 = 123_097_600; + const MINIMUM_FEE_3_ACTIONS: u64 = 134_646_400; + const MINIMUM_FEE_4_ACTIONS: u64 = 146_195_200; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance() as u64; + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + /// Helper to create a dummy action with a unique seed (avoids duplicate nullifiers). + fn create_dummy_action(seed: u8) -> SerializedAction { + SerializedAction { + nullifier: [seed; 32], + rk: [seed.wrapping_add(10); 32], + cmx: [seed.wrapping_add(20); 32], + encrypted_note: vec![seed.wrapping_add(30); 216], + cv_net: [seed.wrapping_add(40); 32], + spend_auth_sig: [seed.wrapping_add(50); 64], + } + } + + // --- Insufficient fee tests (dummy bundles — fee check runs before proof verification) --- + + #[test] + fn test_zero_fee_returns_invalid_value_balance_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // 2 actions with zero fee — rejected at structure validation since + // value_balance == 0 is invalid (it IS the fee for shielded transfers) + let transition = create_shielded_transfer_transition( + vec![create_dummy_action(1), create_dummy_action(2)], + 0, // zero fee + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_fee_one_below_minimum_for_2_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // 2 actions with fee one credit below minimum + let transition = create_shielded_transfer_transition( + vec![create_dummy_action(1), create_dummy_action(2)], + MINIMUM_FEE_2_ACTIONS - 1, // 121,343,999 + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InsufficientShieldedFeeError(_)) + )] + ); + } + + #[test] + fn test_fee_one_below_minimum_for_3_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // 3 actions with fee one credit below minimum + let transition = create_shielded_transfer_transition( + vec![ + create_dummy_action(1), + create_dummy_action(2), + create_dummy_action(3), + ], + MINIMUM_FEE_3_ACTIONS - 1, // 134,646,399 + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InsufficientShieldedFeeError(_)) + )] + ); + } + + #[test] + fn test_fee_one_below_minimum_for_4_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // 4 actions with fee one credit below minimum + let transition = create_shielded_transfer_transition( + vec![ + create_dummy_action(1), + create_dummy_action(2), + create_dummy_action(3), + create_dummy_action(4), + ], + MINIMUM_FEE_4_ACTIONS - 1, // 146,195,199 + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InsufficientShieldedFeeError(_)) + )] + ); + } + + // --- Exact minimum fee tests (real bundles with valid ZK proofs) --- + + /// Build a valid 2-action Orchard bundle where value_balance equals the desired fee. + /// Spends `spend_amount` and outputs `spend_amount - fee`, so value_balance = fee. + fn build_bundle_with_fee( + fee: u64, + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + let spend_amount = 200_000_000u64; // 200M credits + let output_amount = spend_amount - fee; + + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(spend_amount), rho, rseed).unwrap(); + + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(output_amount), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + serialize_authorized_bundle(&bundle) + } + + #[test] + fn test_exact_minimum_fee_for_2_actions_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_bundle_with_fee(MINIMUM_FEE_2_ACTIONS); + + // Verify the bundle has exactly 2 actions and the expected fee + assert_eq!(actions.len(), 2); + assert_eq!(value_balance, MINIMUM_FEE_2_ACTIONS); + + // Set pool balance large enough to cover the fee deduction + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_transfer_transition( + actions, + value_balance, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[test] + fn test_fee_above_minimum_for_2_actions_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Pay 1 credit more than the minimum + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_bundle_with_fee(MINIMUM_FEE_2_ACTIONS + 1); + + assert_eq!(actions.len(), 2); + assert_eq!(value_balance, MINIMUM_FEE_2_ACTIONS + 1); + + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_transfer_transition( + actions, + value_balance, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + } + + // ========================================== + // SECURITY AUDIT TESTS + // ========================================== + // + // These tests verify vulnerabilities and edge cases discovered + // during a security audit of the shielded transaction system. + // Tests that demonstrate actual vulnerabilities are marked with + // "AUDIT FINDING" comments and document the expected correct behavior. + + mod security_audit { + use super::*; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, + MerklePath, Note, NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + /// Minimum fee for 2 actions (Orchard builder always produces ≥2). + const MINIMUM_FEE_2_ACTIONS: u64 = 123_097_600; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance() as u64; + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + /// Build a valid Orchard bundle for shielded transfer tests. + /// Includes sufficient fee (value_balance = MINIMUM_FEE_2_ACTIONS). + /// Returns (actions, value_balance, anchor_bytes, proof_bytes, binding_sig). + fn build_valid_shielded_transfer_bundle( + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let mut rng = OsRng; + let pk = get_proving_key(); + + let spend_amount = 200_000_000u64; + let output_amount = spend_amount - MINIMUM_FEE_2_ACTIONS; + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(spend_amount), rho, rseed).unwrap(); + + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(output_amount), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + serialize_authorized_bundle(&bundle) + } + + /// AUDIT REGRESSION: Mutating value_balance is now caught by BatchValidator. + /// + /// Previously, the code only called `bundle.verify_proof(vk)` which did not + /// check the binding signature. Now `BatchValidator` verifies the Halo 2 proof + /// AND the binding signature, which cryptographically binds value_balance to + /// the value commitments (cv_net). Mutating value_balance changes the bundle + /// commitment (sighash), causing signature verification to fail. + /// + /// Original severity: CRITICAL — now FIXED. + #[test] + fn test_valid_proof_with_mutated_value_balance_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shielded_transfer_bundle(); + assert_eq!(value_balance, MINIMUM_FEE_2_ACTIONS); + + // ATTACK: Mutate value_balance (increase by 5000 so it still passes fee check) + let mutated_value_balance = value_balance + 5000; + + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_transfer_transition( + actions, + mutated_value_balance, // MUTATED: different from signed value + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: BatchValidator detects the binding signature mismatch + // because mutating value_balance changes the bundle commitment (sighash). + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// AUDIT REGRESSION: Zeroed binding signature is now caught by BatchValidator. + /// + /// Previously accepted because only the Halo 2 proof was verified. + /// Now `BatchValidator` verifies the binding signature as well. + /// + /// Original severity: CRITICAL — now FIXED. + #[test] + fn test_valid_proof_with_zeroed_binding_sig_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (actions, value_balance, anchor_bytes, proof_bytes, _binding_sig) = + build_valid_shielded_transfer_bundle(); + + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_transfer_transition( + actions, + value_balance, + anchor_bytes, + proof_bytes, + [0u8; 64], // ZEROED binding signature + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: BatchValidator detects the invalid binding signature. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// AUDIT REGRESSION: Zeroed spend auth signatures are now caught by BatchValidator. + /// + /// Previously accepted because only the Halo 2 proof was verified. + /// Now `BatchValidator` verifies spend authorization signatures, proving + /// that the spender controls the spending key. + /// + /// Original severity: CRITICAL — now FIXED. + #[test] + fn test_valid_proof_with_zeroed_spend_auth_sig_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let (mut actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shielded_transfer_bundle(); + + // ATTACK: Zero out all spend auth signatures + for action in &mut actions { + action.spend_auth_sig = [0u8; 64]; + } + + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_transfer_transition( + actions, + value_balance, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: BatchValidator detects the invalid spend auth signatures. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// Duplicate nullifiers within the same bundle — proof verification now + /// runs before the intra-bundle dedup check, so the invalid proof is + /// rejected first. + #[test] + fn test_duplicate_nullifiers_in_same_bundle() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let anchor = [42u8; 32]; + insert_anchor_into_state(&platform, &anchor); + + // Two actions with the same nullifier but different cmx + let action1 = create_dummy_serialized_action(); + let mut action2 = create_dummy_serialized_action(); + action2.cmx = [99u8; 32]; // Different commitment + + let transition = create_shielded_transfer_transition( + vec![action1, action2], // Both have nullifier [1u8; 32] + MINIMUM_FEE_2_ACTIONS, // sufficient fee so we reach proof verification + anchor, + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before nullifier dedup, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // PROOF GENERATION & VERIFICATION TESTS + // ========================================== + + mod return_proof { + use super::*; + use dpp::block::block_info::BlockInfo; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::shielded_transfer_transition::accessors::ShieldedTransferTransitionAccessorsV0; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, Note, + NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + const MINIMUM_FEE_2_ACTIONS: u64 = 123_097_600; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, u64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance() as u64; + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_shielded_transfer_prove_and_verify_nullifiers() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + let mut rng = OsRng; + let pk = get_proving_key(); + + let spend_amount = 200_000_000u64; + let output_amount = spend_amount - MINIMUM_FEE_2_ACTIONS; + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(spend_amount), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(output_amount), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // --- Set up pool state --- + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + // --- Build and serialize the transition --- + let transition = create_shielded_transfer_transition( + actions, + value_balance, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + + // --- Process with manual transaction so we can commit before proving --- + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // Commit the transaction so prove_state_transition can read committed state + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // --- Generate proof --- + let proof_result = platform + .drive + .prove_state_transition(&transition, None, platform_version) + .expect("expected to generate proof for shielded transfer"); + + let proof_bytes = proof_result + .into_data() + .expect("expected proof data, not an error"); + + // --- Verify proof --- + let (root_hash, proof_result) = Drive::verify_state_transition_was_executed_with_proof( + &transition, + &BlockInfo::default(), + &proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("expected to verify shielded transfer proof"); + + assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); + + // --- Assert result is VerifiedShieldedNullifiers with all spent --- + let StateTransitionProofResult::VerifiedShieldedNullifiers(statuses) = proof_result + else { + panic!( + "expected VerifiedShieldedNullifiers, got {:?}", + proof_result + ); + }; + + // Extract expected nullifiers from the transition + let StateTransition::ShieldedTransfer(ref st) = transition else { + unreachable!(); + }; + let expected_nullifiers: Vec> = st.nullifiers(); + + assert_eq!( + statuses.len(), + expected_nullifiers.len(), + "should have one status per nullifier" + ); + + for (nf, is_spent) in &statuses { + assert!(is_spent, "nullifier {} should be spent", hex::encode(nf)); + assert!( + expected_nullifiers.contains(nf), + "proved nullifier {} should be one of the transition's nullifiers", + hex::encode(nf) + ); + } + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/transform_into_action/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..4d9a6aa653f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/transform_into_action/v0/mod.rs @@ -0,0 +1,117 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, validate_anchor_exists, validate_nullifiers, +}; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::fee::Credits; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::shielded_transfer::ShieldedTransferTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::shielded_transfer) trait ShieldedTransferStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldedTransferStateTransitionTransformIntoActionValidationV0 for ShieldedTransferTransition { + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // The value_balance is the fee amount extracted from the shielded pool + let fee_amount: Credits = match self { + ShieldedTransferTransition::V0(v0) => v0.value_balance, + }; + + // The anchor from the transition (Merkle root of commitment tree) + let anchor: [u8; 32] = match self { + ShieldedTransferTransition::V0(v0) => v0.anchor, + }; + + // Extract nullifiers from the transition actions + let nullifiers: Vec<[u8; 32]> = match self { + ShieldedTransferTransition::V0(v0) => v0.actions.iter().map(|a| a.nullifier).collect(), + }; + + // Read current shielded pool state from GroveDB + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + // Verify the pool has sufficient balance for the fee + if current_total_balance < fee_amount { + return Ok(ConsensusValidationResult::new_with_error( + StateError::InvalidShieldedProofError( + dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError::new( + format!( + "shielded pool has insufficient balance: pool has {} but fee requires {}", + current_total_balance, fee_amount + ), + ), + ) + .into(), + )); + } + + // Verify the anchor exists in the recorded anchors tree + if let Some(consensus_error) = validate_anchor_exists( + drive, + &anchor, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Validate nullifiers: intra-bundle duplicates + already-spent in state + if let Some(consensus_error) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Calculate fees from the GroveDB operations + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + let result = ShieldedTransferTransitionAction::try_from_transition( + self, + fee_amount, + current_total_balance, + ); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/mod.rs new file mode 100644 index 00000000000..494bb288179 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/mod.rs @@ -0,0 +1,62 @@ +#[cfg(test)] +mod tests; +mod transform_into_action; + +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::state_transitions::shielded::shielded_withdrawal_transition::ShieldedWithdrawalTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::shielded_withdrawal::transform_into_action::v0::ShieldedWithdrawalStateTransitionTransformIntoActionValidationV0; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; + +/// A trait to transform into an action for shielded withdrawal transition +pub trait StateTransitionShieldedWithdrawalTransitionActionTransformer { + /// Transform into an action for shielded withdrawal transition + fn transform_into_action_for_shielded_withdrawal_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionShieldedWithdrawalTransitionActionTransformer for ShieldedWithdrawalTransition { + fn transform_into_action_for_shielded_withdrawal_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .shielded_withdrawal_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform.drive, + block_info, + execution_context, + tx, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "shielded withdrawal transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs new file mode 100644 index 00000000000..eee6a0dee13 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs @@ -0,0 +1,1162 @@ +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, insert_anchor_into_state, insert_dummy_encrypted_notes, + insert_nullifier_into_state, process_transition, set_pool_total_balance, setup_platform, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use assert_matches::assert_matches; + use dpp::consensus::basic::BasicError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::identity::core_script::CoreScript; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shielded_withdrawal_transition::v0::ShieldedWithdrawalTransitionV0; + use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; + use dpp::state_transition::StateTransition; + use dpp::withdrawal::Pooling; + use platform_version::version::PlatformVersion; + + // ========================================== + // Helper Functions (transition-specific) + // ========================================== + + /// Create a dummy CoreScript (P2PKH) for the withdrawal output. + fn create_output_script() -> CoreScript { + CoreScript::new_p2pkh([7u8; 20]) + } + + /// Builds a `ShieldedWithdrawalTransition` state transition. + /// No signing needed since shielded withdrawal transitions have no ECDSA witnesses + /// (authenticated purely via Orchard ZK proof + signatures). + fn create_shielded_withdrawal_transition( + actions: Vec, + unshielding_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + core_fee_per_byte: u32, + pooling: Pooling, + output_script: CoreScript, + ) -> StateTransition { + StateTransition::ShieldedWithdrawal(ShieldedWithdrawalTransition::V0( + ShieldedWithdrawalTransitionV0 { + actions, + unshielding_amount, + anchor, + proof, + binding_signature, + core_fee_per_byte, + pooling, + output_script, + }, + )) + } + + /// Shorthand for creating a structurally valid (but cryptographically invalid) shielded + /// withdrawal transition. Has a non-zero anchor, valid field sizes, positive unshielding_amount. + fn create_default_shielded_withdrawal_transition() -> StateTransition { + create_shielded_withdrawal_transition( + vec![create_dummy_serialized_action()], + 111_549_800, // unshielding_amount: recipient amount + minimum fee for 1 action + [42u8; 32], // non-zero anchor + vec![0u8; 100], // dummy proof bytes + [0u8; 64], // dummy binding signature + 1, // core_fee_per_byte + Pooling::Never, // pooling strategy + create_output_script(), // P2PKH output script + ) + } + + // ========================================== + // STRUCTURE VALIDATION TESTS (BasicError) + // ========================================== + + mod structure_validation { + use super::*; + + #[test] + fn test_empty_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_withdrawal_transition( + vec![], // Empty actions — invalid + 1000, + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedNoActionsError(_)) + )] + ); + } + + #[test] + fn test_too_many_actions_returns_error() { + // NOTE: We call validate_structure directly because 101 actions (~41KB) + // exceeds max_state_transition_size (20KB) before the actions count check + // can trigger. This means ShieldedTooManyActionsError is effectively + // unreachable through the normal pipeline. + use dpp::state_transition::StateTransitionStructureValidation; + + let platform_version = PlatformVersion::latest(); + + // 101 actions exceeds max_shielded_transition_actions (100) + let actions: Vec = + (0..101).map(|_| create_dummy_serialized_action()).collect(); + + let transition = ShieldedWithdrawalTransitionV0 { + actions, + unshielding_amount: 111_549_800, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: create_output_script(), + }; + + let result = transition.validate_structure(platform_version); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedTooManyActionsError(_) + )] + ); + } + + #[test] + fn test_zero_unshielding_amount_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_withdrawal_transition( + vec![create_dummy_serialized_action()], + 0, // Zero unshielding_amount — invalid + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_unshielding_amount_exceeding_i64_max_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_withdrawal_transition( + vec![create_dummy_serialized_action()], + i64::MAX as u64 + 1, // Exceeds i64::MAX + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_withdrawal_transition( + vec![create_dummy_serialized_action()], + 1000, + [42u8; 32], + vec![], // Empty proof — invalid + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedEmptyProofError(_)) + )] + ); + } + + #[test] + fn test_zero_anchor_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_shielded_withdrawal_transition( + vec![create_dummy_serialized_action()], + 1000, + [0u8; 32], // All zeros — invalid + vec![0u8; 100], + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedZeroAnchorError(_)) + )] + ); + } + } + + // ========================================== + // ANCHOR VALIDATION TESTS (StateError) + // ========================================== + + mod anchor_validation { + use super::*; + + #[test] + fn test_insufficient_pool_notes_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Set pool balance so the pool balance check would pass (if it got that far) + set_pool_total_balance(&platform, 10_000); + + // Non-zero anchor that exists in state, but no encrypted notes in pool + let anchor = [42u8; 32]; + insert_anchor_into_state(&platform, &anchor); + + let transition = create_default_shielded_withdrawal_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before pool notes check, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_anchor_not_in_tree_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + // Set pool balance so the balance check passes before anchor validation + set_pool_total_balance(&platform, 10_000); + + // Non-zero anchor that doesn't exist in state + let transition = create_default_shielded_withdrawal_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before anchor validation, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // NULLIFIER DOUBLE-SPEND TESTS (StateError) + // ========================================== + + mod nullifier_validation { + use super::*; + + #[test] + fn test_already_spent_nullifier_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + let nullifier = [1u8; 32]; // Same as create_dummy_serialized_action().nullifier + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Set pool balance so the pool balance check passes + set_pool_total_balance(&platform, 10_000); + + // Insert the nullifier so it appears already spent + insert_nullifier_into_state(&platform, &nullifier); + + let transition = create_default_shielded_withdrawal_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before nullifier validation, so the + // dummy proof data is rejected before the nullifier check is reached. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // ZK PROOF VERIFICATION TESTS (InvalidShieldedProofError) + // ========================================== + + mod proof_verification { + use super::*; + use grovedb_commitment_tree::{ + Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, Note, + NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_invalid_proof_returns_shielded_proof_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Set pool balance so the pool balance check passes + set_pool_total_balance(&platform, 10_000); + + // This transition is structurally valid and has a valid anchor, + // but has random ZK proof data. It should pass structure validation + // and anchor validation but fail at proof verification. + let transition = create_default_shielded_withdrawal_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_valid_shielded_withdrawal_proof_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + let mut rng = OsRng; + let pk = get_proving_key(); + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note with value 500M --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle: spend 500M -> output 5K (value_balance = 499,995,000) --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + // Compute platform sighash binding transparent fields (output_script, unshielding_amount) + let output_script = create_output_script(); + let unshielding_amount = 499_995_000u64; // value_balance as u64 + let mut extra_sighash_data = output_script.as_bytes().to_vec(); + extra_sighash_data.extend_from_slice(&unshielding_amount.to_le_bytes()); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + // --- Extract serialized fields --- + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be 499,995,000 (500M spent - 5K output) + assert_eq!(value_balance, 499_995_000); + + // --- Set up platform state --- + // Insert anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor_bytes); + + // Set pool total balance so the withdrawal has sufficient funds + set_pool_total_balance(&platform, 500_000_000); + + // --- Create and process transition --- + let transition = create_shielded_withdrawal_transition( + actions, + value_balance as u64, // unshielding_amount + anchor_bytes, + proof_bytes, + binding_sig, + 1, // core_fee_per_byte + Pooling::Never, // pooling strategy + output_script, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[test] + fn test_wrong_encrypted_note_size_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Set pool balance so the pool balance check passes + set_pool_total_balance(&platform, 10_000); + + // Create action with wrong encrypted_note size + let mut bad_action = create_dummy_serialized_action(); + bad_action.encrypted_note = vec![0u8; 100]; // 100 bytes instead of 216 + + let transition = create_shielded_withdrawal_transition( + vec![bad_action], + 111_549_800, // unshielding_amount: recipient amount + minimum fee for 1 action + anchor, + vec![0u8; 100], + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // SECURITY AUDIT TESTS + // ========================================== + // + // These tests verify vulnerabilities and edge cases discovered + // during a security audit of the shielded transaction system. + // Tests that demonstrate actual vulnerabilities are marked with + // "AUDIT FINDING" / "AUDIT REGRESSION" comments. + + mod security_audit { + use super::*; + use grovedb_commitment_tree::{ + Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, Note, + NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + /// Build a valid Orchard bundle for shielded withdrawal tests (spend > output). + /// The `output_script` and `unshielding_amount` are bound to the sighash so that + /// the resulting bundle can only be used with those specific transparent fields. + /// Returns (actions, value_balance, anchor_bytes, proof_bytes, binding_sig). + fn build_valid_shielded_withdrawal_bundle( + output_script: &CoreScript, + unshielding_amount: u64, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // Spend 500M -> output 5K -> value_balance = 499,995,000 + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + // Bind transparent fields (output_script, unshielding_amount) to the sighash + let mut extra_sighash_data = output_script.as_bytes().to_vec(); + extra_sighash_data.extend_from_slice(&unshielding_amount.to_le_bytes()); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + serialize_authorized_bundle(&bundle) + } + + /// AUDIT REGRESSION: Zeroed binding signature is caught by BatchValidator. + /// + /// The binding signature cryptographically binds value_balance to the value + /// commitments. Zeroing it out should cause signature verification to fail, + /// preventing an attacker from stripping authentication from a valid bundle. + /// + /// Original severity: CRITICAL — now FIXED. + #[test] + fn test_valid_proof_with_zeroed_binding_sig_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let output_script = create_output_script(); + let unshielding_amount = 499_995_000u64; + let (actions, value_balance, anchor_bytes, proof_bytes, _binding_sig) = + build_valid_shielded_withdrawal_bundle(&output_script, unshielding_amount); + + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_withdrawal_transition( + actions, + value_balance as u64, // unshielding_amount + anchor_bytes, + proof_bytes, + [0u8; 64], // ZEROED binding signature + 1, + Pooling::Never, + output_script, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: BatchValidator detects the invalid binding signature. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// AUDIT REGRESSION: Mutating value_balance is caught by BatchValidator. + /// + /// Previously, the code only called `bundle.verify_proof(vk)` which did not + /// check the binding signature. Now `BatchValidator` verifies the Halo 2 proof + /// AND the binding signature, which cryptographically binds value_balance to + /// the value commitments (cv_net). Mutating value_balance changes the bundle + /// commitment (sighash), causing signature verification to fail. + /// + /// Original severity: CRITICAL — now FIXED. + #[test] + fn test_valid_proof_with_mutated_value_balance_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + // Bundle is signed for create_output_script() with unshielding_amount = 499,995,000 + let output_script = create_output_script(); + let signed_unshielding_amount = 499_995_000u64; + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shielded_withdrawal_bundle(&output_script, signed_unshielding_amount); + assert_eq!(value_balance, 499_995_000); + + // ATTACK: Inflate unshielding_amount from 499,995,000 to 999,000,000 + let mutated_unshielding_amount = 999_000_000u64; + + // Set pool balance high enough for the inflated amount + set_pool_total_balance(&platform, 1_000_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_shielded_withdrawal_transition( + actions, + mutated_unshielding_amount, // MUTATED: was 499,995,000, now 999,000,000 + anchor_bytes, + proof_bytes, + binding_sig, + 1, + Pooling::Never, + output_script, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: BatchValidator detects the binding signature mismatch. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// AUDIT REGRESSION: Different output_script is caught by platform sighash. + /// + /// The output_script is bound to the Orchard bundle via sighash. Changing + /// the script after signing causes the sighash to differ from the one used + /// during signing, and signature verification fails. This prevents an + /// attacker from redirecting withdrawal funds to a different L1 address. + /// + /// Original severity: HIGH — now FIXED. + #[test] + fn test_different_output_script_with_same_valid_bundle_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + // Bundle is signed for the ORIGINAL output_script with unshielding_amount = 499,995,000 + let original_script = create_output_script(); + let unshielding_amount = 499_995_000u64; + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shielded_withdrawal_bundle(&original_script, unshielding_amount); + assert_eq!(value_balance, 499_995_000); + + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + // ATTACK: Use a completely different output script (attacker's address) + let attacker_script = CoreScript::new_p2pkh([0xAA; 20]); + + let transition = create_shielded_withdrawal_transition( + actions, + unshielding_amount, + anchor_bytes, + proof_bytes, + binding_sig, + 1, + Pooling::Never, + attacker_script, // ATTACKER's script, not the original + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: Platform sighash includes output_script, so changing it + // causes the sighash to differ from the one used during signing, + // and signature verification fails. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// AUDIT REGRESSION: Different unshielding_amount is caught by platform sighash. + /// + /// The unshielding_amount is bound to the Orchard bundle via sighash. Changing + /// the withdrawal amount after signing causes the sighash to differ, and + /// signature verification fails. This prevents an attacker from inflating + /// the credited withdrawal amount. + #[test] + fn test_different_unshielding_amount_with_same_valid_bundle_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let output_script = create_output_script(); + let signed_unshielding_amount = 499_995_000u64; + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_shielded_withdrawal_bundle(&output_script, signed_unshielding_amount); + assert_eq!(value_balance, 499_995_000); + + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + // ATTACK: Use a different unshielding_amount + let manipulated_unshielding_amount = 400_000_000u64; + + let transition = create_shielded_withdrawal_transition( + actions, + manipulated_unshielding_amount, // MANIPULATED: was 499,995,000, now 400,000,000 + anchor_bytes, + proof_bytes, + binding_sig, + 1, + Pooling::Never, + output_script, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Platform sighash includes unshielding_amount, so changing it causes + // signature verification to fail. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// Duplicate nullifiers within the same bundle — proof verification now + /// runs before the intra-bundle dedup check, so the invalid proof is + /// rejected first. + #[test] + fn test_duplicate_nullifiers_in_same_bundle() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + insert_anchor_into_state(&platform, &anchor); + set_pool_total_balance(&platform, 10_000); + + let action1 = create_dummy_serialized_action(); + let mut action2 = create_dummy_serialized_action(); + action2.cmx = [99u8; 32]; // Different commitment but same nullifier + + let transition = create_shielded_withdrawal_transition( + vec![action1, action2], // Both have nullifier [1u8; 32] + 123_098_600, // unshielding_amount: recipient amount + minimum fee for 2 actions + anchor, + vec![0u8; 100], + [0u8; 64], + 1, + Pooling::Never, + create_output_script(), + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before nullifier dedup, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // RETURN PROOF TESTS (prove + verify round-trip) + // ========================================== + + mod return_proof { + use super::*; + use dpp::block::block_info::BlockInfo; + use dpp::data_contracts::withdrawals_contract; + use dpp::data_contracts::withdrawals_contract::v1::document_types::withdrawal; + use dpp::document::Document; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::shielded_withdrawal_transition::accessors::ShieldedWithdrawalTransitionAccessorsV0; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, Note, + NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::collections::BTreeMap; + use std::sync::{Arc, OnceLock}; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_shielded_withdrawal_prove_and_verify_nullifiers_and_document() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + let mut rng = OsRng; + let pk = get_proving_key(); + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note with value 500M --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle: spend 500M -> output 5K (value_balance = 499,995,000) --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + // Compute platform sighash binding transparent fields (output_script, unshielding_amount) + let output_script = create_output_script(); + let unshielding_amount = 499_995_000u64; // value_balance as u64 + let mut extra_sighash_data = output_script.as_bytes().to_vec(); + extra_sighash_data.extend_from_slice(&unshielding_amount.to_le_bytes()); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + // --- Extract serialized fields --- + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be 499,995,000 (500M spent - 5K output) + assert_eq!(value_balance, 499_995_000); + + // --- Set up platform state --- + insert_anchor_into_state(&platform, &anchor_bytes); + set_pool_total_balance(&platform, 500_000_000); + + // --- Create and process transition --- + let transition = create_shielded_withdrawal_transition( + actions, + value_balance as u64, // unshielding_amount + anchor_bytes, + proof_bytes, + binding_sig, + 1, // core_fee_per_byte + Pooling::Never, // pooling strategy + output_script.clone(), + ); + + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + + // --- Process with manual transaction so we can commit before proving --- + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // Commit the transaction so prove_state_transition can read committed state + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // --- Generate proof --- + let proof_result = platform + .drive + .prove_state_transition(&transition, None, platform_version) + .expect("expected to generate proof for shielded withdrawal"); + + let proof_bytes = proof_result + .into_data() + .expect("expected proof data, not an error"); + + // --- Verify proof --- + // ShieldedWithdrawal verification requires the withdrawals system data contract + // to look up the withdrawal document type. + let withdrawals_data_contract = + load_system_data_contract(SystemDataContract::Withdrawals, platform_version) + .expect("should load withdrawals contract"); + + let withdrawals_data_contract = Arc::new(withdrawals_data_contract); + + let (root_hash, proof_result) = Drive::verify_state_transition_was_executed_with_proof( + &transition, + &BlockInfo::default(), + &proof_bytes, + &|id| { + if *id == withdrawals_contract::ID { + Ok(Some(Arc::clone(&withdrawals_data_contract))) + } else { + Ok(None) + } + }, + platform_version, + ) + .expect("expected to verify shielded withdrawal proof"); + + assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); + + // --- Assert result is VerifiedShieldedNullifiersWithWithdrawalDocument --- + let StateTransitionProofResult::VerifiedShieldedNullifiersWithWithdrawalDocument( + statuses, + documents, + ) = proof_result + else { + panic!( + "expected VerifiedShieldedNullifiersWithWithdrawalDocument, got {:?}", + proof_result + ); + }; + + // Extract expected nullifiers from the transition + let StateTransition::ShieldedWithdrawal(ref st) = transition else { + unreachable!(); + }; + let expected_nullifiers: Vec> = st.nullifiers(); + + assert_eq!( + statuses.len(), + expected_nullifiers.len(), + "should have one status per nullifier" + ); + + // All nullifiers must be marked as spent + for (nf, is_spent) in &statuses { + assert!(is_spent, "nullifier {} should be spent", hex::encode(nf)); + assert!( + expected_nullifiers.contains(nf), + "proved nullifier {} should be one of the transition's nullifiers", + hex::encode(nf) + ); + } + + // Compute the expected withdrawal document ID (same logic as prove/verify sides) + let first_nullifier = expected_nullifiers + .first() + .expect("should have at least one nullifier"); + let mut entropy = Vec::new(); + entropy.extend_from_slice(first_nullifier); + entropy.extend_from_slice(output_script.as_bytes()); + let expected_document_id = Document::generate_document_id_v0( + &withdrawals_contract::ID, + &withdrawals_contract::OWNER_ID, + withdrawal::NAME, + &entropy, + ); + + // The documents map should contain exactly one entry with the expected document_id + assert_eq!( + documents.len(), + 1, + "should have exactly one withdrawal document entry" + ); + assert!( + documents.contains_key(&expected_document_id), + "documents map should contain the expected withdrawal document id {}", + expected_document_id + ); + + // The document should exist (Some) — it was created during processing + let maybe_doc = documents.get(&expected_document_id).unwrap(); + assert!( + maybe_doc.is_some(), + "withdrawal document should be present (not absent)" + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/mod.rs new file mode 100644 index 00000000000..f60ce30ca28 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(in crate::execution::validation::state_transition::state_transitions::shielded_withdrawal) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..f028db8dd4d --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/transform_into_action/v0/mod.rs @@ -0,0 +1,133 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, + validate_nullifiers, +}; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::shielded_withdrawal::ShieldedWithdrawalTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::shielded_withdrawal) trait ShieldedWithdrawalStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldedWithdrawalStateTransitionTransformIntoActionValidationV0 + for ShieldedWithdrawalTransition +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // The anchor from the transition (Merkle root of commitment tree) + let anchor: [u8; 32] = match self { + ShieldedWithdrawalTransition::V0(v0) => v0.anchor, + }; + + // Extract nullifiers from the transition actions + let nullifiers: Vec<[u8; 32]> = match self { + ShieldedWithdrawalTransition::V0(v0) => { + v0.actions.iter().map(|a| a.nullifier).collect() + } + }; + + // Read current shielded pool total balance from GroveDB + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + // Check minimum notes threshold for outgoing transitions (anonymity set) + if let Some(consensus_error) = validate_minimum_pool_notes( + drive, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Verify the pool has sufficient balance for the withdrawal. + let unshielding_amount = match self { + ShieldedWithdrawalTransition::V0(v0) => v0.unshielding_amount, + }; + + if current_total_balance < unshielding_amount { + return Ok(ConsensusValidationResult::new_with_error( + StateError::InvalidShieldedProofError( + dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError::new( + format!( + "shielded pool has insufficient balance: pool has {} but withdrawal requires {}", + current_total_balance, unshielding_amount + ), + ), + ) + .into(), + )); + } + + // Verify the anchor exists in the recorded anchors tree + if let Some(consensus_error) = validate_anchor_exists( + drive, + &anchor, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Validate nullifiers: intra-bundle duplicates + already-spent in state + if let Some(consensus_error) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Calculate fees from the GroveDB operations + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + // Build the action, which includes creating the withdrawal document + let creation_time_ms = block_info.time_ms; + + let result = ShieldedWithdrawalTransitionAction::try_from_transition( + self, + current_total_balance, + creation_time_ms, + ); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs index f25e081e185..5d0ccf7dee7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/test_helpers.rs @@ -2,11 +2,14 @@ //! //! This module provides common test infrastructure for testing state transitions //! that involve platform addresses, including signers, address creation helpers, -//! and balance setup utilities. +//! balance setup utilities, and shielded pool state helpers. +use crate::config::{PlatformConfig, PlatformTestConfig}; +use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; use crate::rpc::core::MockCoreRPCLike; -use crate::test::helpers::setup::TempPlatform; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::address_funds::{AddressWitness, PlatformAddress}; +use dpp::block::block_info::BlockInfo; use dpp::dashcore::blockdata::script::ScriptBuf; use dpp::dashcore::hashes::{sha256, Hash}; use dpp::dashcore::secp256k1::{PublicKey as RawPublicKey, Secp256k1, SecretKey as RawSecretKey}; @@ -14,7 +17,16 @@ use dpp::dashcore::PublicKey; use dpp::identity::signer::Signer; use dpp::platform_value::BinaryData; use dpp::prelude::AddressNonce; +use dpp::serialization::PlatformSerializable; +use dpp::shielded::SerializedAction; +use dpp::state_transition::StateTransition; use dpp::ProtocolError; +use drive::drive::shielded::paths::{ + shielded_credit_pool_anchors_path, shielded_credit_pool_notes_path, + shielded_credit_pool_nullifiers_path, shielded_credit_pool_path, SHIELDED_NOTES_KEY, + SHIELDED_TOTAL_BALANCE_KEY, +}; +use drive::grovedb::Element; use platform_version::version::PlatformVersion; use std::collections::HashMap; @@ -387,3 +399,209 @@ pub fn setup_address_with_balance_and_system_credits( ) .expect("expected to apply drive operations"); } + +// ========================================== +// Shielded Test Helpers +// ========================================== + +/// Create a `SerializedAction` with syntactically valid sizes but meaningless crypto data. +/// Passes structure validation (correct field sizes) but will fail ZK proof verification. +pub fn create_dummy_serialized_action() -> SerializedAction { + SerializedAction { + nullifier: [1u8; 32], + rk: [2u8; 32], + cmx: [3u8; 32], + encrypted_note: vec![4u8; 216], // epk(32) + enc(104) + out(80) + cv_net: [5u8; 32], + spend_auth_sig: [6u8; 64], + } +} + +/// Standard platform setup for shielded tests with instant lock signature verification disabled. +pub fn setup_platform() -> TempPlatform { + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + TestPlatformBuilder::new() + .with_config(platform_config) + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state() +} + +/// Execute a state transition through the full processing pipeline and return the result. +pub fn process_transition( + platform: &TempPlatform, + transition: StateTransition, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition") +} + +/// Insert a fake anchor into the shielded anchors tree via GroveDB. +/// Anchors are stored as anchor_bytes → block_height_be in [AddressBalances, "s", [6]]. +pub fn insert_anchor_into_state(platform: &TempPlatform, anchor: &[u8; 32]) { + let platform_version = PlatformVersion::latest(); + let grove_version = &platform_version.drive.grove_version; + let transaction = platform.drive.grove.start_transaction(); + let anchors_path = shielded_credit_pool_anchors_path(); + + platform + .drive + .grove + .insert( + &anchors_path, + anchor, + Element::new_item(0u64.to_be_bytes().to_vec()), + None, + Some(&transaction), + grove_version, + ) + .unwrap() + .expect("should insert anchor"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("should commit transaction"); +} + +/// Insert a nullifier into the nullifiers tree via GroveDB. +pub fn insert_nullifier_into_state(platform: &TempPlatform, nullifier: &[u8; 32]) { + let platform_version = PlatformVersion::latest(); + let grove_version = &platform_version.drive.grove_version; + let transaction = platform.drive.grove.start_transaction(); + let nullifiers_path = shielded_credit_pool_nullifiers_path(); + + platform + .drive + .grove + .insert( + &nullifiers_path, + nullifier, + Element::Item(vec![], None), + None, + Some(&transaction), + grove_version, + ) + .unwrap() + .expect("should insert nullifier"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("should commit transaction"); +} + +/// Set the shielded pool total balance in GroveDB. +pub fn set_pool_total_balance(platform: &TempPlatform, balance: u64) { + let platform_version = PlatformVersion::latest(); + let grove_version = &platform_version.drive.grove_version; + let transaction = platform.drive.grove.start_transaction(); + let pool_path = shielded_credit_pool_path(); + + platform + .drive + .grove + .insert( + &pool_path, + &[SHIELDED_TOTAL_BALANCE_KEY], + Element::new_sum_item(balance as i64), + None, + Some(&transaction), + grove_version, + ) + .unwrap() + .expect("should set total balance"); + + // The shielded pool is part of total system credits, so ensure system credits + // cover the pool balance (needed for RemoveFromSystemCredits in withdrawals). + platform + .drive + .add_to_system_credits(balance, Some(&transaction), platform_version) + .expect("should add to system credits"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("should commit transaction"); +} + +/// Insert dummy notes into the CommitmentTree to meet the minimum +/// notes threshold for outgoing transitions. +/// Uses `commitment_tree_insert` to properly update the Sinsemilla frontier. +pub fn insert_dummy_encrypted_notes(platform: &TempPlatform, count: u64) { + use grovedb_commitment_tree::{DashMemo, NoteBytesData, TransmittedNoteCiphertext}; + + let platform_version = PlatformVersion::latest(); + let grove_version = &platform_version.drive.grove_version; + let pool_path = shielded_credit_pool_path(); + + for i in 0..count { + // Generate a deterministic dummy cmx from the index. + // Use a valid Pallas base field element (just set it to a small value). + let mut cmx = [0u8; 32]; + cmx[..8].copy_from_slice(&(i + 1).to_le_bytes()); + + // Build a dummy TransmittedNoteCiphertext (216 bytes total) + let mut epk_bytes = [0u8; 32]; + epk_bytes[..8].copy_from_slice(&(i + 1).to_le_bytes()); + let enc_ciphertext = NoteBytesData([0u8; 104]); + let out_ciphertext = [0u8; 80]; + let ciphertext: TransmittedNoteCiphertext = + TransmittedNoteCiphertext::from_parts(epk_bytes, enc_ciphertext, out_ciphertext); + + let dummy_rho = [0u8; 32]; // dummy nullifier for rho derivation + + let transaction = platform.drive.grove.start_transaction(); + platform + .drive + .grove + .commitment_tree_insert( + &pool_path, + &[SHIELDED_NOTES_KEY], + cmx, + dummy_rho, + ciphertext, + Some(&transaction), + grove_version, + ) + .unwrap() + .expect("should insert dummy note"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("should commit transaction"); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/mod.rs new file mode 100644 index 00000000000..3d12c471f22 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/mod.rs @@ -0,0 +1,64 @@ +mod transform_into_action; + +#[cfg(test)] +mod tests; + +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::unshield_transition::UnshieldTransition; +use dpp::validation::ConsensusValidationResult; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::unshield::transform_into_action::v0::UnshieldStateTransitionTransformIntoActionValidationV0; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use crate::platform_types::platform_state::PlatformStateV0Methods; + +/// A trait to transform into an action for unshield transition +pub trait StateTransitionUnshieldTransitionActionTransformer { + /// Transform into an action for unshield transition + fn transform_into_action_for_unshield_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error>; +} + +impl StateTransitionUnshieldTransitionActionTransformer for UnshieldTransition { + fn transform_into_action_for_unshield_transition( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .unshield_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform.drive, + tx, + block_info, + execution_context, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "unshield transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs new file mode 100644 index 00000000000..17ee144ec02 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs @@ -0,0 +1,984 @@ +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::shielded_common::compute_platform_sighash; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + create_dummy_serialized_action, insert_anchor_into_state, insert_dummy_encrypted_notes, + insert_nullifier_into_state, process_transition, set_pool_total_balance, setup_platform, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use assert_matches::assert_matches; + use dpp::address_funds::PlatformAddress; + use dpp::consensus::basic::BasicError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::shielded::SerializedAction; + use dpp::state_transition::unshield_transition::v0::UnshieldTransitionV0; + use dpp::state_transition::unshield_transition::UnshieldTransition; + use dpp::state_transition::StateTransition; + use platform_version::version::PlatformVersion; + + // ========================================== + // Helper Functions (transition-specific) + // ========================================== + + /// Create a dummy PlatformAddress for the output. + fn create_output_address() -> PlatformAddress { + let mut hash = [0u8; 20]; + hash[0] = 42; + hash[19] = 42; + PlatformAddress::P2pkh(hash) + } + + /// Builds an `UnshieldTransition` state transition. + /// No signing needed since unshield transitions have no witnesses. + fn create_unshield_transition( + output_address: PlatformAddress, + actions: Vec, + unshielding_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + ) -> StateTransition { + StateTransition::Unshield(UnshieldTransition::V0(UnshieldTransitionV0 { + output_address, + actions, + unshielding_amount, + anchor, + proof, + binding_signature, + })) + } + + /// Shorthand for creating a structurally valid (but cryptographically invalid) unshield + /// transition. Has a non-zero anchor, valid field sizes, positive unshielding_amount. + fn create_default_unshield_transition() -> StateTransition { + create_unshield_transition( + create_output_address(), + vec![create_dummy_serialized_action()], + 111_549_800, // unshielding_amount: recipient amount + minimum fee for 1 action + [42u8; 32], // non-zero anchor + vec![0u8; 100], // dummy proof bytes + [0u8; 64], // dummy binding signature + ) + } + + // ========================================== + // STRUCTURE VALIDATION TESTS (BasicError) + // ========================================== + + mod structure_validation { + use super::*; + + #[test] + fn test_empty_actions_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_unshield_transition( + create_output_address(), + vec![], // Empty actions — invalid + 1000, + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedNoActionsError(_)) + )] + ); + } + + #[test] + fn test_too_many_actions_returns_error() { + // NOTE: We call validate_structure directly because 101 actions (~41KB) + // exceeds max_state_transition_size (20KB) before the actions count check + // can trigger. This means ShieldedTooManyActionsError is effectively + // unreachable through the normal pipeline. + use dpp::state_transition::StateTransitionStructureValidation; + + let platform_version = PlatformVersion::latest(); + + // 101 actions exceeds max_shielded_transition_actions (100) + let actions: Vec = + (0..101).map(|_| create_dummy_serialized_action()).collect(); + + let transition = UnshieldTransitionV0 { + output_address: create_output_address(), + actions, + unshielding_amount: 111_549_800, + anchor: [42u8; 32], + proof: vec![0u8; 100], + binding_signature: [0u8; 64], + }; + + let result = transition.validate_structure(platform_version); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::ShieldedTooManyActionsError(_) + )] + ); + } + + #[test] + fn test_zero_unshielding_amount_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_unshield_transition( + create_output_address(), + vec![create_dummy_serialized_action()], + 0, // Zero unshielding_amount — invalid + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_unshielding_amount_exceeding_i64_max_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_unshield_transition( + create_output_address(), + vec![create_dummy_serialized_action()], + i64::MAX as u64 + 1, // Exceeds i64::MAX + [42u8; 32], + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedInvalidValueBalanceError(_)) + )] + ); + } + + #[test] + fn test_empty_proof_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_unshield_transition( + create_output_address(), + vec![create_dummy_serialized_action()], + 1000, + [42u8; 32], + vec![], // Empty proof — invalid + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedEmptyProofError(_)) + )] + ); + } + + #[test] + fn test_zero_anchor_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + let transition = create_unshield_transition( + create_output_address(), + vec![create_dummy_serialized_action()], + 1000, + [0u8; 32], // All zeros — invalid + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::ShieldedZeroAnchorError(_)) + )] + ); + } + } + + // ========================================== + // ANCHOR VALIDATION TESTS (StateError) + // ========================================== + + mod anchor_validation { + use super::*; + + #[test] + fn test_insufficient_pool_notes_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + // Non-zero anchor that exists in state, but no encrypted notes in pool + let anchor = [42u8; 32]; + insert_anchor_into_state(&platform, &anchor); + + let transition = create_default_unshield_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before pool notes check, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_invalid_anchor_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + // Non-zero anchor that doesn't exist in state + let transition = create_default_unshield_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before anchor validation, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // NULLIFIER DOUBLE-SPEND TESTS (StateError) + // ========================================== + + mod nullifier_validation { + use super::*; + + #[test] + fn test_nullifier_already_spent_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + let nullifier = [1u8; 32]; // Same as create_dummy_serialized_action().nullifier + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Insert the nullifier so it appears already spent + insert_nullifier_into_state(&platform, &nullifier); + + let transition = create_default_unshield_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before nullifier validation, so the + // dummy proof data is rejected before the nullifier check is reached. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // ZK PROOF VERIFICATION TESTS (InvalidShieldedProofError) + // ========================================== + + mod proof_verification { + use super::*; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, + MerklePath, Note, NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_invalid_proof_returns_shielded_proof_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // This transition is structurally valid and has a valid anchor, + // but has random ZK proof data. It should pass structure validation + // and anchor validation but fail at proof verification. + let transition = create_default_unshield_transition(); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + #[test] + fn test_valid_unshield_proof_succeeds() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + let mut rng = OsRng; + let pk = get_proving_key(); + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note with value 500M --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle: spend 500M -> output 5K (value_balance = 499,995,000) --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + // Compute platform sighash binding transparent fields (output_address, unshielding_amount) + let output_address = create_output_address(); + let unshielding_amount = 499_995_000u64; // value_balance as u64 + let mut extra_sighash_data = output_address.to_bytes(); + extra_sighash_data.extend_from_slice(&unshielding_amount.to_le_bytes()); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + // --- Extract serialized fields --- + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be 499,995,000 (500M spent - 5K output) + assert_eq!(value_balance, 499_995_000); + + // --- Set up platform state --- + // Insert anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor_bytes); + + // Set pool total balance so the unshield has sufficient funds + set_pool_total_balance(&platform, 500_000_000); + + // --- Create and process transition --- + let transition = create_unshield_transition( + output_address, + actions, + value_balance as u64, // unshielding_amount + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[test] + fn test_wrong_encrypted_note_size_returns_error() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + + // Insert the anchor so anchor validation passes + insert_anchor_into_state(&platform, &anchor); + + // Create action with wrong encrypted_note size + let mut bad_action = create_dummy_serialized_action(); + bad_action.encrypted_note = vec![0u8; 100]; // 100 bytes instead of 216 + + let transition = create_unshield_transition( + create_output_address(), + vec![bad_action], + 111_549_800, // unshielding_amount: recipient amount + minimum fee for 1 action + anchor, + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // SECURITY AUDIT TESTS + // ========================================== + + mod security_audit { + use super::*; + use grovedb_commitment_tree::{ + Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, + MerklePath, Note, NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + /// Build a valid Orchard bundle for unshield tests (spend > output). + /// The `output_address` and `unshielding_amount` are bound to the sighash so that + /// the resulting bundle can only be used with those specific transparent fields. + /// Returns (actions, value_balance, anchor_bytes, proof_bytes, binding_sig). + fn build_valid_unshield_bundle( + output_address: &PlatformAddress, + unshielding_amount: u64, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let mut rng = OsRng; + let pk = get_proving_key(); + + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(500_000_000), rho, rseed).unwrap(); + + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // Spend 500M -> output 5K -> value_balance = 499,995,000 + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output(None, recipient, NoteValue::from_raw(5_000), [0u8; 36]) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + // Bind transparent fields (output_address, unshielding_amount) to the sighash + let mut extra_sighash_data = output_address.to_bytes(); + extra_sighash_data.extend_from_slice(&unshielding_amount.to_le_bytes()); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + serialize_authorized_bundle(&bundle) + } + + /// AUDIT REGRESSION: Mutating value_balance is now caught by BatchValidator. + /// + /// Previously, the code only called `bundle.verify_proof(vk)` which did not + /// check the binding signature. Now `BatchValidator` verifies the Halo 2 proof + /// AND the binding signature, which cryptographically binds value_balance to + /// the value commitments (cv_net). Mutating value_balance changes the bundle + /// commitment (sighash), causing signature verification to fail. + /// + /// Original severity: CRITICAL — now FIXED. + #[test] + fn test_valid_proof_with_mutated_value_balance_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + // Bundle is signed for create_output_address() with unshielding_amount = 499,995,000 + let output_address = create_output_address(); + let signed_unshielding_amount = 499_995_000u64; + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_unshield_bundle(&output_address, signed_unshielding_amount); + assert_eq!(value_balance, 499_995_000); + + // ATTACK: Inflate unshielding_amount from 499,995,000 to 999,000,000 + let mutated_unshielding_amount = 999_000_000u64; + + // Set pool balance high enough for the inflated amount + set_pool_total_balance(&platform, 1_000_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + let transition = create_unshield_transition( + output_address, + actions, + mutated_unshielding_amount, // MUTATED: was 499,995,000, now 999,000,000 + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: BatchValidator detects the binding signature mismatch. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// AUDIT REGRESSION: Different output_address is now caught by platform sighash. + /// + /// Previously, the output_address was not bound to the Orchard bundle via + /// sighash, allowing an attacker to substitute a different address while + /// reusing a valid bundle. Now `compute_platform_sighash()` includes the + /// output_address and unshielding_amount in the sighash, so changing the + /// address causes signature verification to fail. + /// + /// Original severity: HIGH — now FIXED. + #[test] + fn test_different_output_address_with_same_valid_bundle_is_rejected() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + // Bundle is signed for the ORIGINAL address with unshielding_amount = 499,995,000 + let original_address = create_output_address(); + let unshielding_amount = 499_995_000u64; + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + build_valid_unshield_bundle(&original_address, unshielding_amount); + assert_eq!(value_balance, 499_995_000); + + set_pool_total_balance(&platform, 500_000_000); + insert_anchor_into_state(&platform, &anchor_bytes); + + // ATTACK: Use a completely different output address + let attacker_address = PlatformAddress::P2pkh([0xAA; 20]); + + let transition = create_unshield_transition( + attacker_address, // ATTACKER's address, not the original recipient + actions, + unshielding_amount, + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // FIXED: Platform sighash includes output_address, so changing it + // causes the sighash to differ from the one used during signing, + // and signature verification fails. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + + /// Duplicate nullifiers within the same bundle — proof verification now + /// runs before the intra-bundle dedup check, so the invalid proof is + /// rejected first. + #[test] + fn test_duplicate_nullifiers_in_same_bundle() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + + let anchor = [42u8; 32]; + insert_anchor_into_state(&platform, &anchor); + set_pool_total_balance(&platform, 10_000); + + let action1 = create_dummy_serialized_action(); + let mut action2 = create_dummy_serialized_action(); + action2.cmx = [99u8; 32]; + + let transition = create_unshield_transition( + create_output_address(), + vec![action1, action2], // Both have nullifier [1u8; 32] + 123_098_600, // unshielding_amount: recipient amount + minimum fee for 2 actions + anchor, + vec![0u8; 100], + [0u8; 64], + ); + + let processing_result = process_transition(&platform, transition, platform_version); + + // Proof verification now runs before nullifier dedup, so the + // dummy proof data is rejected first. + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } + } + + // ========================================== + // PROOF GENERATION & VERIFICATION TESTS + // ========================================== + + mod return_proof { + use super::*; + use dpp::block::block_info::BlockInfo; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::unshield_transition::accessors::UnshieldTransitionAccessorsV0; + use drive::drive::Drive; + use grovedb_commitment_tree::{ + Authorized as OrchardAuthorized, Builder, Bundle, BundleType, + ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, FullViewingKey, Note, + NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, Scope, + SpendAuthorizingKey, SpendingKey, + }; + use rand::rngs::OsRng; + use std::sync::OnceLock; + + static TEST_PROVING_KEY: OnceLock = OnceLock::new(); + fn get_proving_key() -> &'static ProvingKey { + TEST_PROVING_KEY.get_or_init(ProvingKey::build) + } + + fn serialize_authorized_bundle( + bundle: &Bundle, + ) -> (Vec, i64, [u8; 32], Vec, [u8; 64]) { + let actions: Vec = bundle + .actions() + .iter() + .map(|action| { + let enc = action.encrypted_note(); + let mut encrypted_note = Vec::with_capacity(216); + encrypted_note.extend_from_slice(&enc.epk_bytes); + encrypted_note.extend_from_slice(enc.enc_ciphertext.as_ref()); + encrypted_note.extend_from_slice(&enc.out_ciphertext); + SerializedAction { + nullifier: action.nullifier().to_bytes(), + rk: <[u8; 32]>::from(action.rk()), + cmx: action.cmx().to_bytes(), + encrypted_note, + cv_net: action.cv_net().to_bytes(), + spend_auth_sig: <[u8; 64]>::from(action.authorization()), + } + }) + .collect(); + let value_balance = *bundle.value_balance(); + let anchor = bundle.anchor().to_bytes(); + let proof = bundle.authorization().proof().as_ref().to_vec(); + let binding_sig = <[u8; 64]>::from(bundle.authorization().binding_signature()); + (actions, value_balance, anchor, proof, binding_sig) + } + + #[test] + fn test_unshield_prove_and_verify_nullifiers_and_address() { + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, 250); + let mut rng = OsRng; + let pk = get_proving_key(); + + let spend_amount = 500_000_000u64; + let output_amount = 5_000u64; + + // --- Create keys --- + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + let ask = SpendAuthorizingKey::from(&sk); + + // --- Create a spendable note --- + let rho_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + b[0] = 1; + b + }; + let rho = Rho::from_bytes(&rho_bytes).unwrap(); + let rseed = RandomSeed::from_bytes([42u8; 32], &rho).unwrap(); + let note = + Note::from_parts(recipient, NoteValue::from_raw(spend_amount), rho, rseed).unwrap(); + + // --- Build commitment tree and get anchor + merkle path --- + let cmx = ExtractedNoteCommitment::from(note.commitment()); + let mut tree = ClientMemoryCommitmentTree::new(100); + tree.append(cmx.to_bytes(), Retention::Marked).unwrap(); + tree.checkpoint(0u32).unwrap(); + let anchor = tree.anchor().unwrap(); + let merkle_path = tree.witness(Position::from(0u64), 0).unwrap().unwrap(); + + // --- Build bundle: spend 500M -> output 5K (value_balance = 499,995,000) --- + let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + builder.add_spend(fvk.clone(), note, merkle_path).unwrap(); + builder + .add_output( + None, + recipient, + NoteValue::from_raw(output_amount), + [0u8; 36], + ) + .unwrap(); + + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + + // Compute platform sighash binding transparent fields (output_address, unshielding_amount) + let output_address = create_output_address(); + let unshielding_amount = 499_995_000u64; // value_balance as u64 + let mut extra_sighash_data = output_address.to_bytes(); + extra_sighash_data.extend_from_slice(&unshielding_amount.to_le_bytes()); + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[ask]).unwrap(); + + // --- Extract serialized fields --- + let (actions, value_balance, anchor_bytes, proof_bytes, binding_sig) = + serialize_authorized_bundle(&bundle); + + // value_balance should be 499,995,000 (500M spent - 5K output) + assert_eq!(value_balance, 499_995_000); + + // --- Set up platform state --- + insert_anchor_into_state(&platform, &anchor_bytes); + set_pool_total_balance(&platform, 500_000_000); + + // --- Build and serialize the transition --- + let transition = create_unshield_transition( + output_address.clone(), + actions, + value_balance as u64, // unshielding_amount + anchor_bytes, + proof_bytes, + binding_sig, + ); + + let transition_bytes = transition + .serialize_to_bytes() + .expect("should serialize transition"); + + // --- Process with manual transaction so we can commit before proving --- + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // Commit the transaction so prove_state_transition can read committed state + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // --- Generate proof --- + let proof_result = platform + .drive + .prove_state_transition(&transition, None, platform_version) + .expect("expected to generate proof for unshield"); + + let grovedb_proof_bytes = proof_result + .into_data() + .expect("expected proof data, not an error"); + + // --- Verify proof --- + let (root_hash, proof_result) = Drive::verify_state_transition_was_executed_with_proof( + &transition, + &BlockInfo::default(), + &grovedb_proof_bytes, + &|_| Ok(None), + platform_version, + ) + .expect("expected to verify unshield proof"); + + assert_ne!(root_hash, [0u8; 32], "root hash should not be zeroed"); + + // --- Assert result is VerifiedShieldedNullifiersWithAddressInfos --- + let StateTransitionProofResult::VerifiedShieldedNullifiersWithAddressInfos( + statuses, + balances, + ) = proof_result + else { + panic!( + "expected VerifiedShieldedNullifiersWithAddressInfos, got {:?}", + proof_result + ); + }; + + // Extract expected nullifiers from the transition + let StateTransition::Unshield(ref st) = transition else { + unreachable!(); + }; + let expected_nullifiers: Vec> = st.nullifiers(); + + assert_eq!( + statuses.len(), + expected_nullifiers.len(), + "should have one status per nullifier" + ); + + for (nf, is_spent) in &statuses { + assert!(is_spent, "nullifier {} should be spent", hex::encode(nf)); + assert!( + expected_nullifiers.contains(nf), + "proved nullifier {} should be one of the transition's nullifiers", + hex::encode(nf) + ); + } + + // Assert the output address appears in the address balances map + assert!( + balances.contains_key(&output_address), + "output address {:?} should be present in address balances", + output_address + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs new file mode 100644 index 00000000000..2a545a928b1 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/transform_into_action/v0/mod.rs @@ -0,0 +1,121 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, validate_anchor_exists, validate_minimum_pool_notes, + validate_nullifiers, +}; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::unshield_transition::UnshieldTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::unshield) trait UnshieldStateTransitionTransformIntoActionValidationV0 +{ + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl UnshieldStateTransitionTransformIntoActionValidationV0 for UnshieldTransition { + fn transform_into_action_v0( + &self, + drive: &Drive, + transaction: TransactionArg, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // The anchor from the transition (Merkle root of commitment tree) + let anchor: [u8; 32] = match self { + UnshieldTransition::V0(v0) => v0.anchor, + }; + + // Extract nullifiers from the transition actions + let nullifiers: Vec<[u8; 32]> = match self { + UnshieldTransition::V0(v0) => v0.actions.iter().map(|a| a.nullifier).collect(), + }; + + // Read current shielded pool state from GroveDB + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + + // Check minimum notes threshold for outgoing transitions (anonymity set) + if let Some(consensus_error) = validate_minimum_pool_notes( + drive, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Verify the anchor exists in the recorded anchors tree + if let Some(consensus_error) = validate_anchor_exists( + drive, + &anchor, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Validate nullifiers: intra-bundle duplicates + already-spent in state + if let Some(consensus_error) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(consensus_error); + } + + // Calculate fees from the GroveDB operations + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + // Verify the pool has sufficient balance for the unshield amount + let amount = match self { + UnshieldTransition::V0(v0) => v0.unshielding_amount, + }; + + if current_total_balance < amount { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::state::state_error::StateError::InvalidShieldedProofError( + dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError::new( + format!( + "shielded pool has insufficient balance: pool has {} but unshield requires {}", + current_total_balance, amount + ), + ), + ) + .into(), + )); + } + + let result = UnshieldTransitionAction::try_from_transition(self, current_total_balance); + + Ok(result.map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index f8b8d7e0e75..9a9de3e81d6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -7,6 +7,11 @@ use crate::execution::validation::state_transition::address_funds_transfer::Stat use crate::execution::validation::state_transition::identity_create::StateTransitionActionTransformerForIdentityCreateTransitionV0; use crate::execution::validation::state_transition::identity_create_from_addresses::StateTransitionActionTransformerForIdentityCreateFromAddressesTransitionV0; use crate::execution::validation::state_transition::identity_top_up::StateTransitionIdentityTopUpTransitionActionTransformer; +use crate::execution::validation::state_transition::shield::StateTransitionShieldTransitionActionTransformer; +use crate::execution::validation::state_transition::shield_from_asset_lock::StateTransitionShieldFromAssetLockTransitionActionTransformer; +use crate::execution::validation::state_transition::shielded_transfer::StateTransitionShieldedTransferTransitionActionTransformer; +use crate::execution::validation::state_transition::shielded_withdrawal::StateTransitionShieldedWithdrawalTransitionActionTransformer; +use crate::execution::validation::state_transition::unshield::StateTransitionUnshieldTransitionActionTransformer; use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -229,13 +234,52 @@ impl StateTransitionActionTransformer for StateTransition { remaining_address_input_balances.clone(), ) } - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) - | StateTransition::Unshield(_) - | StateTransition::ShieldFromAssetLock(_) - | StateTransition::ShieldedWithdrawal(_) => { - todo!("shielded transitions not yet implemented") + StateTransition::Shield(st) => { + let Some(remaining_address_input_balances) = remaining_address_input_balances + else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "we must have remaining address input balances", + ))); + }; + st.transform_into_action_for_shield_transition( + platform, + remaining_address_input_balances.clone(), + block_info, + execution_context, + tx, + ) + } + StateTransition::ShieldedTransfer(st) => st + .transform_into_action_for_shielded_transfer_transition( + platform, + block_info, + execution_context, + tx, + ), + StateTransition::Unshield(st) => st.transform_into_action_for_unshield_transition( + platform, + block_info, + execution_context, + tx, + ), + StateTransition::ShieldFromAssetLock(st) => { + let signable_bytes = self.signable_bytes()?; + st.transform_into_action_for_shield_from_asset_lock_transition( + platform, + signable_bytes, + validation_mode, + block_info, + execution_context, + tx, + ) } + StateTransition::ShieldedWithdrawal(st) => st + .transform_into_action_for_shielded_withdrawal_transition( + platform, + block_info, + execution_context, + tx, + ), } } } diff --git a/packages/rs-drive-abci/src/main.rs b/packages/rs-drive-abci/src/main.rs index 5f8e332301a..a003e2e2f44 100644 --- a/packages/rs-drive-abci/src/main.rs +++ b/packages/rs-drive-abci/src/main.rs @@ -149,6 +149,15 @@ impl Cli { ) .expect("Failed to open platform"); + // Pre-build the shielded verifying key on a background thread so + // the first shielded transaction doesn't pay the ~5-15s build cost. + std::thread::spawn(|| { + use drive_abci::execution::validation::state_transition::shielded_common::warmup_shielded_verifying_key; + tracing::info!("pre-building shielded verifying key in background"); + warmup_shielded_verifying_key(); + tracing::info!("shielded verifying key is ready"); + }); + server::start(runtime, Arc::new(platform), config, cancel); tracing::info!("drive-abci server is stopped"); diff --git a/packages/rs-drive-abci/src/query/mod.rs b/packages/rs-drive-abci/src/query/mod.rs index e2b4ae0792d..c87f5f73158 100644 --- a/packages/rs-drive-abci/src/query/mod.rs +++ b/packages/rs-drive-abci/src/query/mod.rs @@ -7,6 +7,7 @@ mod prefunded_specialized_balances; mod proofs; mod response_metadata; mod service; +mod shielded; mod system; mod token_queries; mod validator_queries; diff --git a/packages/rs-drive-abci/src/query/service.rs b/packages/rs-drive-abci/src/query/service.rs index 309d8d562f9..ace686e24ad 100644 --- a/packages/rs-drive-abci/src/query/service.rs +++ b/packages/rs-drive-abci/src/query/service.rs @@ -878,6 +878,16 @@ impl PlatformService for QueryService { ) .await } + + // TODO: Re-enable when dapi-grpc shielded protobuf types are available + // async fn get_shielded_encrypted_notes(...) + // async fn get_shielded_anchors(...) + // async fn get_shielded_pool_state(...) + // async fn get_shielded_nullifiers(...) + // async fn get_nullifiers_trunk_state(...) + // async fn get_nullifiers_branch_state(...) + // async fn get_recent_nullifier_changes(...) + // async fn get_recent_compacted_nullifier_changes(...) } #[async_trait] diff --git a/packages/rs-drive-abci/src/query/shielded/anchors/mod.rs b/packages/rs-drive-abci/src/query/shielded/anchors/mod.rs new file mode 100644 index 00000000000..f899099ceb7 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/anchors/mod.rs @@ -0,0 +1,54 @@ +mod v0; + +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_anchors_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_shielded_anchors_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetShieldedAnchorsRequest, GetShieldedAnchorsResponse}; +use dpp::version::PlatformVersion; + +impl Platform { + /// Querying the valid shielded anchors + pub fn query_shielded_anchors( + &self, + GetShieldedAnchorsRequest { version }: GetShieldedAnchorsRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError("could not decode shielded anchors query".to_string()), + )); + }; + + let feature_version_bounds = &platform_version.drive_abci.query.shielded_queries.anchors; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "shielded_anchors".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + match version { + RequestVersion::V0(request_v0) => { + let result = + self.query_shielded_anchors_v0(request_v0, platform_state, platform_version)?; + + Ok(result.map(|response_v0| GetShieldedAnchorsResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/anchors/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/anchors/v0/mod.rs new file mode 100644 index 00000000000..f53ecbe8d64 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/anchors/v0/mod.rs @@ -0,0 +1,76 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_anchors_request::GetShieldedAnchorsRequestV0; +use dapi_grpc::platform::v0::get_shielded_anchors_response::get_shielded_anchors_response_v0::Anchors; +use dapi_grpc::platform::v0::get_shielded_anchors_response::{ + get_shielded_anchors_response_v0, GetShieldedAnchorsResponseV0, +}; +use dpp::check_validation_result_with_data; +use dpp::validation::ValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::shielded::paths::shielded_credit_pool_anchors_path_vec; +use drive::grovedb::query_result_type::QueryResultType; +use drive::grovedb::{PathQuery, Query, SizedQuery}; +use drive::util::grove_operations::GroveDBToUse; + +impl Platform { + pub(super) fn query_shielded_anchors_v0( + &self, + GetShieldedAnchorsRequestV0 { prove }: GetShieldedAnchorsRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path_query = PathQuery { + path: shielded_credit_pool_anchors_path_vec(), + query: SizedQuery { + query: Query::new_range_full(), + limit: None, + offset: None, + }, + }; + + let response = if prove { + let proof = check_validation_result_with_data!(self.drive.grove_get_proved_path_query( + &path_query, + None, + &mut vec![], + &platform_version.drive, + )); + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::Current)?; + + GetShieldedAnchorsResponseV0 { + result: Some(get_shielded_anchors_response_v0::Result::Proof(proof)), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + let (results, _) = self.drive.grove_get_raw_path_query( + &path_query, + None, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )?; + + // Anchors are stored as anchor_bytes → block_height_be; extract keys + let anchors: Vec> = results + .to_key_elements() + .into_iter() + .map(|(key, _element)| key) + .collect(); + + GetShieldedAnchorsResponseV0 { + result: Some(get_shielded_anchors_response_v0::Result::Anchors(Anchors { + anchors, + })), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/encrypted_notes/mod.rs b/packages/rs-drive-abci/src/query/shielded/encrypted_notes/mod.rs new file mode 100644 index 00000000000..6f1a44bff5b --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/encrypted_notes/mod.rs @@ -0,0 +1,65 @@ +mod v0; + +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_encrypted_notes_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_shielded_encrypted_notes_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{ + GetShieldedEncryptedNotesRequest, GetShieldedEncryptedNotesResponse, +}; +use dpp::version::PlatformVersion; + +impl Platform { + /// Querying shielded encrypted notes for wallet sync + pub fn query_shielded_encrypted_notes( + &self, + GetShieldedEncryptedNotesRequest { version }: GetShieldedEncryptedNotesRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError( + "could not decode shielded encrypted notes query".to_string(), + ), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .encrypted_notes; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "shielded_encrypted_notes".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_shielded_encrypted_notes_v0( + request_v0, + platform_state, + platform_version, + )?; + + Ok(result.map(|response_v0| GetShieldedEncryptedNotesResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs new file mode 100644 index 00000000000..bb7ac956fd1 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/encrypted_notes/v0/mod.rs @@ -0,0 +1,148 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_encrypted_notes_request::GetShieldedEncryptedNotesRequestV0; +use dapi_grpc::platform::v0::get_shielded_encrypted_notes_response::get_shielded_encrypted_notes_response_v0::{ + EncryptedNote, EncryptedNotes, +}; +use dapi_grpc::platform::v0::get_shielded_encrypted_notes_response::{ + get_shielded_encrypted_notes_response_v0, GetShieldedEncryptedNotesResponseV0, +}; +use dpp::check_validation_result_with_data; +use dpp::validation::ValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::shielded::paths::{ + shielded_credit_pool_path, shielded_credit_pool_path_vec, SHIELDED_NOTES_KEY, +}; +use drive::grovedb::{PathQuery, Query, QueryItem, SizedQuery, SubqueryBranch}; +use drive::grovedb_path::SubtreePath; +use drive::util::grove_operations::GroveDBToUse; + +impl Platform { + pub(super) fn query_shielded_encrypted_notes_v0( + &self, + GetShieldedEncryptedNotesRequestV0 { + start_index, + count, + prove, + }: GetShieldedEncryptedNotesRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let max_notes = platform_version + .drive_abci + .query + .shielded_queries + .max_encrypted_notes_per_query as u32; + + // start_index must be chunk-aligned (multiple of max_notes) so each + // query touches exactly one MMR chunk or the buffer. + let chunk_size = max_notes as u64; + if start_index % chunk_size != 0 { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument(format!( + "start_index {} is not chunk-aligned; must be a multiple of {}", + start_index, chunk_size + )), + )); + } + + let effective = if count == 0 || count > max_notes { + max_notes + } else { + count + }; + let limit = effective.min(u16::MAX as u32) as u16; + + let response = if prove { + // V1 proof: PathQuery with subquery targeting positions in the CommitmentTree + let end_index = start_index + limit as u64 - 1; + let mut inner_query = Query::new(); + inner_query.insert_range_inclusive( + start_index.to_be_bytes().to_vec()..=end_index.to_be_bytes().to_vec(), + ); + + let path_query = PathQuery { + path: shielded_credit_pool_path_vec(), + query: SizedQuery { + query: Query { + items: vec![QueryItem::Key(vec![SHIELDED_NOTES_KEY])], + default_subquery_branch: SubqueryBranch { + subquery_path: None, + subquery: Some(inner_query.into()), + }, + left_to_right: true, + conditional_subquery_branches: None, + add_parent_tree_on_subquery: false, + }, + limit: None, + offset: None, + }, + }; + + let proof = + check_validation_result_with_data!(self.drive.grove_get_proved_path_query_v1( + &path_query, + &mut vec![], + &platform_version.drive, + )); + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::Current)?; + + GetShieldedEncryptedNotesResponseV0 { + result: Some(get_shielded_encrypted_notes_response_v0::Result::Proof( + proof, + )), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + // Non-proved: loop over commitment_tree_get_value for each position + let pool_path = shielded_credit_pool_path(); + let pool_subtree: SubtreePath<&[u8]> = (&pool_path).into(); + let notes_key: &[u8] = &[SHIELDED_NOTES_KEY]; + + let mut entries = Vec::with_capacity(limit as usize); + for pos in start_index..(start_index + limit as u64) { + let maybe_value = self + .drive + .grove + .commitment_tree_get_value( + pool_subtree.clone(), + notes_key, + pos, + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .map_err(|e| Error::Drive(drive::error::Error::GroveDB(Box::new(e))))?; + + match maybe_value { + // Stored value = cmx (32) || rho (32) || encrypted_note (rest) + Some(value) if value.len() > 64 => { + entries.push(EncryptedNote { + cmx: value[..32].to_vec(), + nullifier: value[32..64].to_vec(), + encrypted_note: value[64..].to_vec(), + }); + } + _ => break, // past end of tree + } + } + + GetShieldedEncryptedNotesResponseV0 { + result: Some( + get_shielded_encrypted_notes_response_v0::Result::EncryptedNotes( + EncryptedNotes { entries }, + ), + ), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/mod.rs b/packages/rs-drive-abci/src/query/shielded/mod.rs new file mode 100644 index 00000000000..4a6c62ce843 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/mod.rs @@ -0,0 +1,9 @@ +// TODO: Re-enable when dapi-grpc shielded protobuf types are available +// mod anchors; +// mod encrypted_notes; +// mod nullifiers; +// mod nullifiers_branch_state; +// mod nullifiers_trunk_state; +// mod pool_state; +// mod recent_compacted_nullifier_changes; +// mod recent_nullifier_changes; diff --git a/packages/rs-drive-abci/src/query/shielded/nullifiers/mod.rs b/packages/rs-drive-abci/src/query/shielded/nullifiers/mod.rs new file mode 100644 index 00000000000..096a9820122 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/nullifiers/mod.rs @@ -0,0 +1,61 @@ +mod v0; + +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_nullifiers_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_shielded_nullifiers_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetShieldedNullifiersRequest, GetShieldedNullifiersResponse}; +use dpp::version::PlatformVersion; + +impl Platform { + /// Querying shielded nullifier spend status + pub fn query_shielded_nullifiers( + &self, + GetShieldedNullifiersRequest { version }: GetShieldedNullifiersRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError("could not decode shielded nullifiers query".to_string()), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .nullifiers; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "shielded_nullifiers".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_shielded_nullifiers_v0( + request_v0, + platform_state, + platform_version, + )?; + + Ok(result.map(|response_v0| GetShieldedNullifiersResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/nullifiers/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/nullifiers/v0/mod.rs new file mode 100644 index 00000000000..b0fb644f87c --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/nullifiers/v0/mod.rs @@ -0,0 +1,123 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_nullifiers_request::GetShieldedNullifiersRequestV0; +use dapi_grpc::platform::v0::get_shielded_nullifiers_response::get_shielded_nullifiers_response_v0::{ + NullifierStatus, NullifierStatuses, +}; +use dapi_grpc::platform::v0::get_shielded_nullifiers_response::{ + get_shielded_nullifiers_response_v0, GetShieldedNullifiersResponseV0, +}; +use dpp::check_validation_result_with_data; +use dpp::validation::ValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::shielded::paths::{ + shielded_credit_pool_nullifiers_path, shielded_credit_pool_nullifiers_path_vec, +}; +use drive::error::query::QuerySyntaxError; +use drive::grovedb::{PathQuery, Query, SizedQuery}; +use drive::util::grove_operations::{DirectQueryType, GroveDBToUse}; + +impl Platform { + pub(super) fn query_shielded_nullifiers_v0( + &self, + GetShieldedNullifiersRequestV0 { nullifiers, prove }: GetShieldedNullifiersRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let max_elements = platform_version.drive_abci.query.max_returned_elements as usize; + if nullifiers.len() > max_elements { + return Ok(QueryValidationResult::new_with_error(QueryError::Query( + QuerySyntaxError::InvalidLimit(format!( + "trying to check {} nullifiers, maximum is {}", + nullifiers.len(), + max_elements + )), + ))); + } + + if nullifiers.is_empty() { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument("nullifiers list must not be empty".to_string()), + )); + } + + // Validate that all nullifiers are exactly 32 bytes + for (i, nullifier) in nullifiers.iter().enumerate() { + if nullifier.len() != 32 { + return Ok(QueryValidationResult::new_with_error( + QueryError::InvalidArgument(format!( + "nullifier at index {} has invalid length: expected 32 bytes, got {}", + i, + nullifier.len() + )), + )); + } + } + + let response = if prove { + let path_query = PathQuery { + path: shielded_credit_pool_nullifiers_path_vec(), + query: SizedQuery { + query: { + let mut q = Query::new(); + q.insert_keys(nullifiers.clone()); + q + }, + limit: None, + offset: None, + }, + }; + + let proof = check_validation_result_with_data!(self.drive.grove_get_proved_path_query( + &path_query, + None, + &mut vec![], + &platform_version.drive, + )); + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::Current)?; + + GetShieldedNullifiersResponseV0 { + result: Some(get_shielded_nullifiers_response_v0::Result::Proof(proof)), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + let nullifiers_path = shielded_credit_pool_nullifiers_path(); + + let entries: Vec = nullifiers + .into_iter() + .map(|nullifier| { + let is_spent = self.drive.grove_has_raw( + (&nullifiers_path).into(), + &nullifier, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + )?; + + Ok(NullifierStatus { + nullifier, + is_spent, + }) + }) + .collect::, Error>>()?; + + GetShieldedNullifiersResponseV0 { + result: Some( + get_shielded_nullifiers_response_v0::Result::NullifierStatuses( + NullifierStatuses { entries }, + ), + ), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/nullifiers_branch_state/mod.rs b/packages/rs-drive-abci/src/query/shielded/nullifiers_branch_state/mod.rs new file mode 100644 index 00000000000..f50032376dc --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/nullifiers_branch_state/mod.rs @@ -0,0 +1,62 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_nullifiers_branch_state_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_nullifiers_branch_state_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetNullifiersBranchStateRequest, GetNullifiersBranchStateResponse}; +use dpp::version::PlatformVersion; +mod v0; + +impl Platform { + /// Querying of the nullifiers branch state (merk proof for sync) + pub fn query_nullifiers_branch_state( + &self, + GetNullifiersBranchStateRequest { version }: GetNullifiersBranchStateRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError( + "could not decode nullifiers branch state query".to_string(), + ), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .nullifiers_branch_state; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "nullifiers_branch_state".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_nullifiers_branch_state_v0( + request_v0, + platform_state, + platform_version, + )?; + Ok(result.map(|response_v0| GetNullifiersBranchStateResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/nullifiers_branch_state/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/nullifiers_branch_state/v0/mod.rs new file mode 100644 index 00000000000..322ac20bcc4 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/nullifiers_branch_state/v0/mod.rs @@ -0,0 +1,41 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_nullifiers_branch_state_request::GetNullifiersBranchStateRequestV0; +use dapi_grpc::platform::v0::get_nullifiers_branch_state_response::GetNullifiersBranchStateResponseV0; +use dpp::version::PlatformVersion; + +impl Platform { + pub(super) fn query_nullifiers_branch_state_v0( + &self, + GetNullifiersBranchStateRequestV0 { + pool_type, + pool_identifier, + key, + depth, + checkpoint_height, + }: GetNullifiersBranchStateRequestV0, + _platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let pool_id = if pool_identifier.is_empty() { + None + } else { + Some(pool_identifier) + }; + + let merk_proof = self.drive.prove_nullifiers_branch_query( + pool_type, + pool_id, + key, + depth as u8, + checkpoint_height, + platform_version, + )?; + + let response = GetNullifiersBranchStateResponseV0 { merk_proof }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/nullifiers_trunk_state/mod.rs b/packages/rs-drive-abci/src/query/shielded/nullifiers_trunk_state/mod.rs new file mode 100644 index 00000000000..728b32f9131 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/nullifiers_trunk_state/mod.rs @@ -0,0 +1,62 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_nullifiers_trunk_state_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_nullifiers_trunk_state_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetNullifiersTrunkStateRequest, GetNullifiersTrunkStateResponse}; +use dpp::version::PlatformVersion; +mod v0; + +impl Platform { + /// Querying of the nullifiers trunk state (proof for sync) + pub fn query_nullifiers_trunk_state( + &self, + GetNullifiersTrunkStateRequest { version }: GetNullifiersTrunkStateRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError( + "could not decode nullifiers trunk state query".to_string(), + ), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .nullifiers_trunk_state; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "nullifiers_trunk_state".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_nullifiers_trunk_state_v0( + request_v0, + platform_state, + platform_version, + )?; + Ok(result.map(|response_v0| GetNullifiersTrunkStateResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/nullifiers_trunk_state/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/nullifiers_trunk_state/v0/mod.rs new file mode 100644 index 00000000000..2b754d850d5 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/nullifiers_trunk_state/v0/mod.rs @@ -0,0 +1,44 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_nullifiers_trunk_state_request::GetNullifiersTrunkStateRequestV0; +use dapi_grpc::platform::v0::get_nullifiers_trunk_state_response::GetNullifiersTrunkStateResponseV0; +use dpp::check_validation_result_with_data; +use dpp::validation::ValidationResult; +use dpp::version::PlatformVersion; +use drive::util::grove_operations::GroveDBToUse; + +impl Platform { + pub(super) fn query_nullifiers_trunk_state_v0( + &self, + GetNullifiersTrunkStateRequestV0 { + pool_type, + pool_identifier, + }: GetNullifiersTrunkStateRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let pool_id = if pool_identifier.is_empty() { + None + } else { + Some(pool_identifier) + }; + + let proof = check_validation_result_with_data!(self.drive.prove_nullifiers_trunk_query( + pool_type, + pool_id, + platform_version + )); + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::LatestCheckpoint)?; + + let response = GetNullifiersTrunkStateResponseV0 { + proof: Some(proof), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/pool_state/mod.rs b/packages/rs-drive-abci/src/query/shielded/pool_state/mod.rs new file mode 100644 index 00000000000..3ea9d875763 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/pool_state/mod.rs @@ -0,0 +1,61 @@ +mod v0; + +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_pool_state_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_shielded_pool_state_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetShieldedPoolStateRequest, GetShieldedPoolStateResponse}; +use dpp::version::PlatformVersion; + +impl Platform { + /// Querying the shielded pool state (total balance) + pub fn query_shielded_pool_state( + &self, + GetShieldedPoolStateRequest { version }: GetShieldedPoolStateRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError("could not decode shielded pool state query".to_string()), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .pool_state; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "shielded_pool_state".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_shielded_pool_state_v0( + request_v0, + platform_state, + platform_version, + )?; + + Ok(result.map(|response_v0| GetShieldedPoolStateResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/pool_state/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/pool_state/v0/mod.rs new file mode 100644 index 00000000000..c71ef44fd2c --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/pool_state/v0/mod.rs @@ -0,0 +1,75 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_shielded_pool_state_request::GetShieldedPoolStateRequestV0; +use dapi_grpc::platform::v0::get_shielded_pool_state_response::{ + get_shielded_pool_state_response_v0, GetShieldedPoolStateResponseV0, +}; +use dpp::check_validation_result_with_data; +use dpp::validation::ValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::shielded::paths::{ + shielded_credit_pool_path, shielded_credit_pool_path_vec, SHIELDED_TOTAL_BALANCE_KEY, +}; +use drive::grovedb::{PathQuery, Query, SizedQuery}; +use drive::util::grove_operations::{DirectQueryType, GroveDBToUse}; + +impl Platform { + pub(super) fn query_shielded_pool_state_v0( + &self, + GetShieldedPoolStateRequestV0 { prove }: GetShieldedPoolStateRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let response = if prove { + let path_query = PathQuery { + path: shielded_credit_pool_path_vec(), + query: SizedQuery { + query: Query::new_single_key(vec![SHIELDED_TOTAL_BALANCE_KEY]), + limit: Some(1), + offset: None, + }, + }; + + let proof = check_validation_result_with_data!(self.drive.grove_get_proved_path_query( + &path_query, + None, + &mut vec![], + &platform_version.drive, + )); + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::Current)?; + + GetShieldedPoolStateResponseV0 { + result: Some(get_shielded_pool_state_response_v0::Result::Proof(proof)), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + let pool_path = shielded_credit_pool_path(); + + let total_balance = self + .drive + .grove_get_raw_value_u64_from_encoded_var_vec( + (&pool_path).into(), + &[SHIELDED_TOTAL_BALANCE_KEY], + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + )? + .unwrap_or(0); + + GetShieldedPoolStateResponseV0 { + result: Some(get_shielded_pool_state_response_v0::Result::TotalBalance( + total_balance, + )), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/recent_compacted_nullifier_changes/mod.rs b/packages/rs-drive-abci/src/query/shielded/recent_compacted_nullifier_changes/mod.rs new file mode 100644 index 00000000000..61d778b2682 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/recent_compacted_nullifier_changes/mod.rs @@ -0,0 +1,67 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_recent_compacted_nullifier_changes_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_recent_compacted_nullifier_changes_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{ + GetRecentCompactedNullifierChangesRequest, GetRecentCompactedNullifierChangesResponse, +}; +use dpp::version::PlatformVersion; + +mod v0; + +impl Platform { + /// Querying of recent compacted nullifier changes + pub fn query_recent_compacted_nullifier_changes( + &self, + GetRecentCompactedNullifierChangesRequest { version }: GetRecentCompactedNullifierChangesRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError( + "could not decode recent compacted nullifier changes query".to_string(), + ), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .recent_compacted_nullifier_changes; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "recent_compacted_nullifier_changes".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_recent_compacted_nullifier_changes_v0( + request_v0, + platform_state, + platform_version, + )?; + Ok( + result.map(|response_v0| GetRecentCompactedNullifierChangesResponse { + version: Some(ResponseVersion::V0(response_v0)), + }), + ) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/recent_compacted_nullifier_changes/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/recent_compacted_nullifier_changes/v0/mod.rs new file mode 100644 index 00000000000..9d91eb7ce93 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/recent_compacted_nullifier_changes/v0/mod.rs @@ -0,0 +1,76 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_recent_compacted_nullifier_changes_request::GetRecentCompactedNullifierChangesRequestV0; +use dapi_grpc::platform::v0::get_recent_compacted_nullifier_changes_response::{ + get_recent_compacted_nullifier_changes_response_v0, + GetRecentCompactedNullifierChangesResponseV0, +}; +use dapi_grpc::platform::v0::{CompactedBlockNullifierChanges, CompactedNullifierUpdateEntries}; +use dpp::version::PlatformVersion; +use drive::util::grove_operations::GroveDBToUse; + +impl Platform { + pub(super) fn query_recent_compacted_nullifier_changes_v0( + &self, + GetRecentCompactedNullifierChangesRequestV0 { + start_block_height, + prove, + }: GetRecentCompactedNullifierChangesRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let limit = Some(25u16); + + let response = if prove { + let proof = self.drive.prove_compacted_nullifier_changes( + start_block_height, + limit, + None, + platform_version, + )?; + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::Current)?; + + GetRecentCompactedNullifierChangesResponseV0 { + result: Some( + get_recent_compacted_nullifier_changes_response_v0::Result::Proof(proof), + ), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + let compacted_nullifier_changes = self.drive.fetch_compacted_nullifier_changes( + start_block_height, + limit, + None, + platform_version, + )?; + + let compacted_block_changes: Vec = + compacted_nullifier_changes + .into_iter() + .map(|change| CompactedBlockNullifierChanges { + start_block_height: change.start_block, + end_block_height: change.end_block, + nullifiers: change.nullifiers.iter().map(|n| n.to_vec()).collect(), + }) + .collect(); + + GetRecentCompactedNullifierChangesResponseV0 { + result: Some( + get_recent_compacted_nullifier_changes_response_v0::Result::CompactedNullifierUpdateEntries( + CompactedNullifierUpdateEntries { compacted_block_changes }, + ), + ), + metadata: Some( + self.response_metadata_v0(platform_state, CheckpointUsed::Current), + ), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/recent_nullifier_changes/mod.rs b/packages/rs-drive-abci/src/query/shielded/recent_nullifier_changes/mod.rs new file mode 100644 index 00000000000..8409234c17d --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/recent_nullifier_changes/mod.rs @@ -0,0 +1,65 @@ +use crate::error::query::QueryError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_recent_nullifier_changes_request::Version as RequestVersion; +use dapi_grpc::platform::v0::get_recent_nullifier_changes_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{ + GetRecentNullifierChangesRequest, GetRecentNullifierChangesResponse, +}; +use dpp::version::PlatformVersion; + +mod v0; + +impl Platform { + /// Querying of recent nullifier changes + pub fn query_recent_nullifier_changes( + &self, + GetRecentNullifierChangesRequest { version }: GetRecentNullifierChangesRequest, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(version) = version else { + return Ok(QueryValidationResult::new_with_error( + QueryError::DecodingError( + "could not decode recent nullifier changes query".to_string(), + ), + )); + }; + + let feature_version_bounds = &platform_version + .drive_abci + .query + .shielded_queries + .recent_nullifier_changes; + + let feature_version = match &version { + RequestVersion::V0(_) => 0, + }; + if !feature_version_bounds.check_version(feature_version) { + return Ok(QueryValidationResult::new_with_error( + QueryError::UnsupportedQueryVersion( + "recent_nullifier_changes".to_string(), + feature_version_bounds.min_version, + feature_version_bounds.max_version, + platform_version.protocol_version, + feature_version, + ), + )); + } + + match version { + RequestVersion::V0(request_v0) => { + let result = self.query_recent_nullifier_changes_v0( + request_v0, + platform_state, + platform_version, + )?; + Ok(result.map(|response_v0| GetRecentNullifierChangesResponse { + version: Some(ResponseVersion::V0(response_v0)), + })) + } + } + } +} diff --git a/packages/rs-drive-abci/src/query/shielded/recent_nullifier_changes/v0/mod.rs b/packages/rs-drive-abci/src/query/shielded/recent_nullifier_changes/v0/mod.rs new file mode 100644 index 00000000000..4c952187b81 --- /dev/null +++ b/packages/rs-drive-abci/src/query/shielded/recent_nullifier_changes/v0/mod.rs @@ -0,0 +1,71 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::query::response_metadata::CheckpointUsed; +use crate::query::QueryValidationResult; +use dapi_grpc::platform::v0::get_recent_nullifier_changes_request::GetRecentNullifierChangesRequestV0; +use dapi_grpc::platform::v0::get_recent_nullifier_changes_response::{ + get_recent_nullifier_changes_response_v0, GetRecentNullifierChangesResponseV0, +}; +use dapi_grpc::platform::v0::{BlockNullifierChanges, NullifierUpdateEntries}; +use dpp::version::PlatformVersion; +use drive::util::grove_operations::GroveDBToUse; + +impl Platform { + pub(super) fn query_recent_nullifier_changes_v0( + &self, + GetRecentNullifierChangesRequestV0 { + start_height, + prove, + }: GetRecentNullifierChangesRequestV0, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let limit = Some(100u16); + + let response = if prove { + let proof = self.drive.prove_recent_nullifier_changes( + start_height, + limit, + None, + platform_version, + )?; + + let (grovedb_used, proof) = + self.response_proof_v0(platform_state, proof, GroveDBToUse::Current)?; + + GetRecentNullifierChangesResponseV0 { + result: Some(get_recent_nullifier_changes_response_v0::Result::Proof( + proof, + )), + metadata: Some(self.response_metadata_v0(platform_state, grovedb_used)), + } + } else { + let nullifier_changes = self.drive.fetch_recent_nullifier_changes( + start_height, + limit, + None, + platform_version, + )?; + + let block_changes: Vec = nullifier_changes + .into_iter() + .map(|change| BlockNullifierChanges { + block_height: change.block_height, + nullifiers: change.nullifiers.iter().map(|n| n.to_vec()).collect(), + }) + .collect(); + + GetRecentNullifierChangesResponseV0 { + result: Some( + get_recent_nullifier_changes_response_v0::Result::NullifierUpdateEntries( + NullifierUpdateEntries { block_changes }, + ), + ), + metadata: Some(self.response_metadata_v0(platform_state, CheckpointUsed::Current)), + } + }; + + Ok(QueryValidationResult::new_with_data(response)) + } +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/execution.rs b/packages/rs-drive-abci/tests/strategy_tests/execution.rs index b8c31197500..ea10c6eb6a1 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/execution.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/execution.rs @@ -975,6 +975,7 @@ pub(crate) fn continue_chain_for_strategy( let mut state_transitions_per_block = BTreeMap::new(); let mut state_transition_results_per_block = BTreeMap::new(); + let mut shielded_state: Option = None; for block_height in block_start..(block_start + block_count) { let state = platform.state.load(); @@ -1023,6 +1024,7 @@ pub(crate) fn continue_chain_for_strategy( &mut signer, &mut rng, &instant_lock_quorums, + &mut shielded_state, ); state_transitions_per_block.insert(block_height, state_transitions.clone()); diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index c656b34544a..3f1d5eeeeae 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -3,8 +3,28 @@ use crate::query::QueryStrategy; use dpp::block::block_info::BlockInfo; use dpp::dashcore::{Network, PrivateKey}; use dpp::dashcore::{ProTxHash, QuorumHash}; +// TODO: Re-enable when OperationType has shielded variants +// use dpp::shielded::{compute_platform_sighash, SerializedAction}; use dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; +// TODO: Re-enable when OperationType has shielded variants +// use dpp::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; +// use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; +// use dpp::state_transition::shield_transition::methods::ShieldTransitionMethodsV0; +// use dpp::state_transition::shield_transition::ShieldTransition; +// use dpp::state_transition::shielded_transfer_transition::methods::ShieldedTransferTransitionMethodsV0; +// use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; +// use dpp::state_transition::shielded_withdrawal_transition::methods::ShieldedWithdrawalTransitionMethodsV0; +// use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; +// use dpp::state_transition::unshield_transition::methods::UnshieldTransitionMethodsV0; +// use dpp::state_transition::unshield_transition::UnshieldTransition; use dpp::ProtocolError; +// TODO: Re-enable when OperationType has shielded variants +// use grovedb_commitment_tree::{ +// Anchor, Authorized as OrchardAuthorized, Builder, Bundle, BundleType, +// ClientMemoryCommitmentTree, DashMemo, ExtractedNoteCommitment, Flags as OrchardFlags, +// FullViewingKey, MerklePath, Note, NoteValue, Position, ProvingKey, RandomSeed, Retention, Rho, +// Scope, SpendAuthorizingKey, SpendingKey, +// }; use dpp::dashcore::secp256k1::SecretKey; use dpp::data_contract::document_type::random_document::CreateRandomDocument; @@ -109,6 +129,8 @@ use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::ops::RangeInclusive; use std::str::FromStr; +// TODO: Re-enable when OperationType has shielded variants +// use std::sync::OnceLock; use strategy_tests::transitions::{ create_identity_credit_transfer_to_addresses_transition, create_identity_credit_transfer_to_addresses_transition_with_outputs, @@ -119,6 +141,39 @@ use strategy_tests::transitions::{ use strategy_tests::Strategy; use tenderdash_abci::proto::abci::{ExecTxResult, ValidatorSetUpdate}; +// TODO: Re-enable when OperationType has shielded variants +// /// Cached Orchard proving key for strategy tests (~30s to build, reused across tests). +// static TEST_PROVING_KEY: OnceLock = OnceLock::new(); +// +// fn get_proving_key() -> &'static ProvingKey { +// TEST_PROVING_KEY.get_or_init(ProvingKey::build) +// } +// +// /// Deterministic Orchard spending key seed used throughout all shielded strategy tests. +// const TEST_SK_BYTES: [u8; 32] = [0u8; 32]; + +/// Stub type for shielded pool state in strategy tests. +/// TODO: Re-enable full implementation when OperationType has shielded variants. +/// The full implementation with commitment tree tracking, spendable notes, and +/// Orchard key management is commented out below until the shielded OperationType +/// variants (Shield, ShieldFromAssetLock, ShieldedTransfer, Unshield, +/// ShieldedWithdrawal) are added back to the OperationType enum. +pub struct ShieldedState; + +// TODO: Re-enable when OperationType has shielded variants +// Original ShieldedState had fields: tree (ClientMemoryCommitmentTree), +// spendable_notes, checkpoint_counter, sk, fvk, ask, rho_counter +// and methods: new(), record_shielded_note(), checkpoint(), +// take_spendable_note(), has_spendable_notes() +// +// Also commented out: serialize_authorized_bundle() function and +// the 5 helper methods on NetworkStrategy: +// create_shield_transition() +// create_shield_from_asset_lock_transition() +// create_shielded_transfer_transition() +// create_unshield_transition() +// create_shielded_withdrawal_transition() + #[derive(Clone, Debug, Default)] pub struct MasternodeListChangesStrategy { /// How many new hpmns on average per core chain lock increase @@ -614,6 +669,7 @@ impl NetworkStrategy { instant_lock_quorums: &Quorums, rng: &mut StdRng, platform_version: &PlatformVersion, + _shielded_state: &mut Option, // TODO: Re-enable when OperationType has shielded variants ) -> (Vec, Vec) { let mut maybe_state = None; let mut operations = vec![]; @@ -1833,6 +1889,103 @@ impl NetworkStrategy { operations.push(batch_transition); } + // TODO: Re-enable when OperationType has shielded variants + // OperationType::Shield(amount_range) => { + // for _i in 0..count { + // let Some(state_transition) = self.create_shield_transition( + // current_addresses_with_balance, + // amount_range, + // signer, + // rng, + // platform_version, + // ) else { + // break; + // }; + // // Record the shielded note for potential future spends. + // // The value is |-value_balance| since value_balance is negative + // // for shield transitions (money flowing into the pool). + // if let StateTransition::Shield(ref shield) = state_transition { + // let shielded_value = match shield { + // ShieldTransition::V0(v0) => (-v0.amount) as u64, + // }; + // let state = shielded_state.get_or_insert_with(ShieldedState::new); + // state.record_shielded_note(shielded_value); + // state.checkpoint(); + // } + // operations.push(state_transition); + // } + // } + // OperationType::ShieldFromAssetLock(amount_range) => { + // for _i in 0..count { + // let Some(state_transition) = self + // .create_shield_from_asset_lock_transition( + // amount_range, + // rng, + // instant_lock_quorums, + // &platform.config, + // platform_version, + // ) + // else { + // break; + // }; + // // Record the shielded note for potential future spends + // if let StateTransition::ShieldFromAssetLock(ref shield) = + // state_transition + // { + // let shielded_value = match shield { + // ShieldFromAssetLockTransition::V0(v0) => { + // (-v0.amount) as u64 + // } + // }; + // let state = shielded_state.get_or_insert_with(ShieldedState::new); + // state.record_shielded_note(shielded_value); + // state.checkpoint(); + // } + // operations.push(state_transition); + // } + // } + // OperationType::ShieldedTransfer(amount_range) => { + // for _i in 0..count { + // let Some(state_transition) = self.create_shielded_transfer_transition( + // amount_range, + // rng, + // shielded_state, + // platform_version, + // ) else { + // break; + // }; + // operations.push(state_transition); + // } + // } + // OperationType::Unshield(amount_range) => { + // for _i in 0..count { + // let Some(state_transition) = self.create_unshield_transition( + // current_addresses_with_balance, + // amount_range, + // rng, + // shielded_state, + // platform_version, + // ) else { + // break; + // }; + // operations.push(state_transition); + // } + // } + // OperationType::ShieldedWithdrawal(amount_range) => { + // for _i in 0..count { + // let Some(state_transition) = self + // .create_shielded_withdrawal_transition( + // amount_range, + // rng, + // shielded_state, + // platform_version, + // ) + // else { + // break; + // }; + // operations.push(state_transition); + // } + // } _ => {} } } @@ -1853,6 +2006,7 @@ impl NetworkStrategy { signer: &mut SimpleSigner, rng: &mut StdRng, instant_lock_quorums: &Quorums, + shielded_state: &mut Option, ) -> (Vec, Vec) { let mut finalize_block_operations = vec![]; let platform_state = platform.state.load(); @@ -1907,6 +2061,7 @@ impl NetworkStrategy { instant_lock_quorums, rng, platform_version, + shielded_state, ); finalize_block_operations.append(&mut add_to_finalize_block_operations); state_transitions.append(&mut operation_based_state_transitions); @@ -2452,6 +2607,438 @@ impl NetworkStrategy { Some(funding_transition) } + + // TODO: Re-enable when OperationType has shielded variants + // /// Build a Shield state transition (transparent addresses -> shielded pool). + // /// + // /// Creates an output-only Orchard bundle (no spends) with a real Halo 2 proof, + // /// signs the address input witnesses, and returns the transition. + // fn create_shield_transition( + // &mut self, + // current_addresses_with_balance: &mut AddressesWithBalance, + // amount_range: &AmountRange, + // signer: &mut SimpleSigner, + // rng: &mut StdRng, + // platform_version: &PlatformVersion, + // ) -> Option { + // // 1. Pick input addresses with sufficient balances + // let inputs = + // current_addresses_with_balance.take_random_amounts_with_range(amount_range, rng)?; + // + // let total_input: Credits = inputs.values().map(|(_, credits)| credits).sum(); + // + // tracing::debug!(?inputs, total_input, "Preparing shield transition"); + // + // // 2. Create deterministic Orchard recipient (same key each time is fine for testing) + // let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + // let fvk = FullViewingKey::from(&sk); + // let recipient = fvk.address_at(0u32, Scope::External); + // + // // 3. Build output-only Orchard bundle (shield = outputs only, no spends) + // let anchor = Anchor::empty_tree(); + // let mut builder = Builder::::new( + // BundleType::Transactional { + // flags: OrchardFlags::SPENDS_DISABLED, + // bundle_required: false, + // }, + // anchor, + // ); + // + // // Use total_input as the shielded value (fee will be deducted from inputs) + // // value_balance will be negative (money flowing into the pool) + // let shield_value = total_input; + // builder + // .add_output( + // None, + // recipient, + // NoteValue::from_raw(shield_value), + // [0u8; 36], + // ) + // .expect("expected to add output"); + // + // // 4. Build -> prove -> sign + // let pk = get_proving_key(); + // let mut bundle_rng = rand::rngs::OsRng; + // let (unauthorized, _) = builder + // .build::(&mut bundle_rng) + // .expect("expected to build bundle") + // .expect("expected bundle to be present"); + // + // let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // let sighash = compute_platform_sighash(&bundle_commitment, &[]); + // let proven = unauthorized + // .create_proof(pk, &mut bundle_rng) + // .expect("expected to create proof"); + // let bundle = proven + // .apply_signatures(bundle_rng, sighash, &[]) + // .expect("expected to apply signatures"); + // + // // 5. Decompose bundle into platform serialization fields + // let (actions, flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + // serialize_authorized_bundle(&bundle); + // + // // 6. Build ShieldTransition with signed address witnesses + // let fee_strategy: AddressFundsFeeStrategy = + // vec![AddressFundsFeeStrategyStep::DeductFromInput(0)].into(); + // + // let shield_transition = ShieldTransition::try_from_bundle_with_signer( + // inputs, + // actions, + // flags, + // value_balance, + // anchor_bytes, + // proof_bytes, + // binding_sig, + // fee_strategy, + // signer, + // 0, + // platform_version, + // ) + // .expect("expected to create shield transition"); + // + // tracing::debug!("Shield transition successfully built and signed"); + // + // Some(shield_transition) + // } + // + // /// Build a ShieldFromAssetLock state transition (core asset lock -> shielded pool). + // /// + // /// Like Shield, this is output-only (no spends). The funds come from a core + // /// asset lock proof rather than platform address inputs. + // fn create_shield_from_asset_lock_transition( + // &mut self, + // amount_range: &AmountRange, + // rng: &mut StdRng, + // instant_lock_quorums: &Quorums, + // platform_config: &PlatformConfig, + // platform_version: &PlatformVersion, + // ) -> Option { + // // 1. Create asset lock proof + // let (asset_lock_proof, asset_lock_private_key, funded_amount) = self + // .create_asset_lock_proof_with_amount( + // rng, + // amount_range, + // instant_lock_quorums, + // platform_config, + // platform_version, + // ); + // + // tracing::debug!(funded_amount, "Preparing shield from asset lock transition"); + // + // // 2. Create deterministic Orchard recipient + // let sk = SpendingKey::from_bytes(TEST_SK_BYTES).unwrap(); + // let fvk = FullViewingKey::from(&sk); + // let recipient = fvk.address_at(0u32, Scope::External); + // + // // 3. Build output-only Orchard bundle (same as Shield) + // let anchor = Anchor::empty_tree(); + // let mut builder = Builder::::new( + // BundleType::Transactional { + // flags: OrchardFlags::SPENDS_DISABLED, + // bundle_required: false, + // }, + // anchor, + // ); + // + // builder + // .add_output( + // None, + // recipient, + // NoteValue::from_raw(funded_amount), + // [0u8; 36], + // ) + // .expect("expected to add output"); + // + // // 4. Build -> prove -> sign + // let pk = get_proving_key(); + // let mut bundle_rng = rand::rngs::OsRng; + // let (unauthorized, _) = builder + // .build::(&mut bundle_rng) + // .expect("expected to build bundle") + // .expect("expected bundle to be present"); + // + // let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // let sighash = compute_platform_sighash(&bundle_commitment, &[]); + // let proven = unauthorized + // .create_proof(pk, &mut bundle_rng) + // .expect("expected to create proof"); + // let bundle = proven + // .apply_signatures(bundle_rng, sighash, &[]) + // .expect("expected to apply signatures"); + // + // // 5. Decompose bundle + // let (actions, flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + // serialize_authorized_bundle(&bundle); + // + // // 6. Build ShieldFromAssetLockTransition + // let transition = ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle( + // asset_lock_proof, + // asset_lock_private_key.as_slice(), + // actions, + // flags, + // value_balance, + // anchor_bytes, + // proof_bytes, + // binding_sig, + // 0, + // platform_version, + // ) + // .expect("expected to create shield from asset lock transition"); + // + // tracing::debug!("ShieldFromAssetLock transition successfully built and signed"); + // + // Some(transition) + // } + // + // /// Build a ShieldedTransfer state transition (shielded pool -> shielded pool). + // /// + // /// Spends an existing note and creates a new note with the same value. + // /// Requires notes from prior Shield or ShieldFromAssetLock transitions. + // fn create_shielded_transfer_transition( + // &mut self, + // _amount_range: &AmountRange, + // _rng: &mut StdRng, + // shielded_state: &mut Option, + // platform_version: &PlatformVersion, + // ) -> Option { + // let state = shielded_state.as_mut()?; + // if !state.has_spendable_notes() { + // tracing::debug!("No spendable notes available for shielded transfer"); + // return None; + // } + // + // let (note, merkle_path, anchor) = state.take_spendable_note()?; + // let note_value = note.value().inner(); + // + // tracing::debug!(note_value, "Building shielded transfer bundle"); + // + // let fvk = state.fvk.clone(); + // let ask = state.ask.clone(); + // let recipient = fvk.address_at(0u32, Scope::External); + // + // // Build bundle: spend note -> output same value (value_balance = 0) + // let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + // builder + // .add_spend(fvk, note, merkle_path) + // .expect("expected to add spend"); + // builder + // .add_output(None, recipient, NoteValue::from_raw(note_value), [0u8; 36]) + // .expect("expected to add output"); + // + // let pk = get_proving_key(); + // let mut bundle_rng = rand::rngs::OsRng; + // let (unauthorized, _) = builder + // .build::(&mut bundle_rng) + // .expect("expected to build bundle") + // .expect("expected bundle to be present"); + // + // // Shielded transfer has no extra_data in sighash + // let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // let sighash = compute_platform_sighash(&bundle_commitment, &[]); + // let proven = unauthorized + // .create_proof(pk, &mut bundle_rng) + // .expect("expected to create proof"); + // let bundle = proven + // .apply_signatures(bundle_rng, sighash, &[ask]) + // .expect("expected to apply signatures"); + // + // let (actions, flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + // serialize_authorized_bundle(&bundle); + // + // // value_balance should be 0 (all value stays in pool) + // // Cast i64 to u64 for the ShieldedTransferTransition API + // let transition = ShieldedTransferTransition::try_from_bundle( + // actions, + // flags, + // value_balance as u64, + // anchor_bytes, + // proof_bytes, + // binding_sig, + // platform_version, + // ) + // .expect("expected to create shielded transfer transition"); + // + // tracing::debug!("ShieldedTransfer transition successfully built"); + // + // Some(transition) + // } + // + // /// Build an Unshield state transition (shielded pool -> platform address). + // /// + // /// Spends an existing note and sends the value to a platform address. + // /// Requires notes from prior Shield or ShieldFromAssetLock transitions. + // fn create_unshield_transition( + // &mut self, + // _current_addresses_with_balance: &mut AddressesWithBalance, + // _amount_range: &AmountRange, + // _rng: &mut StdRng, + // shielded_state: &mut Option, + // platform_version: &PlatformVersion, + // ) -> Option { + // let state = shielded_state.as_mut()?; + // if !state.has_spendable_notes() { + // tracing::debug!("No spendable notes available for unshield"); + // return None; + // } + // + // let (note, merkle_path, anchor) = state.take_spendable_note()?; + // let note_value = note.value().inner(); + // + // tracing::debug!(note_value, "Building unshield bundle"); + // + // let fvk = state.fvk.clone(); + // let ask = state.ask.clone(); + // let recipient = fvk.address_at(0u32, Scope::External); + // + // // Spend full note, output half back to pool, unshield the other half + // let unshield_amount = note_value / 2; + // let change_amount = note_value - unshield_amount; + // + // // Build bundle: spend note -> output change (value_balance = unshield_amount) + // let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + // builder + // .add_spend(fvk, note, merkle_path) + // .expect("expected to add spend"); + // builder + // .add_output( + // None, + // recipient, + // NoteValue::from_raw(change_amount), + // [0u8; 36], + // ) + // .expect("expected to add output"); + // + // let pk = get_proving_key(); + // let mut bundle_rng = rand::rngs::OsRng; + // let (unauthorized, _) = builder + // .build::(&mut bundle_rng) + // .expect("expected to build bundle") + // .expect("expected bundle to be present"); + // + // // Unshield extra_data = output_address.to_bytes() || amount.to_le_bytes() + // let output_address = PlatformAddress::P2pkh([42u8; 20]); + // let amount = unshield_amount; + // let mut extra_sighash_data = output_address.to_bytes(); + // extra_sighash_data.extend_from_slice(&amount.to_le_bytes()); + // + // let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + // let proven = unauthorized + // .create_proof(pk, &mut bundle_rng) + // .expect("expected to create proof"); + // let bundle = proven + // .apply_signatures(bundle_rng, sighash, &[ask]) + // .expect("expected to apply signatures"); + // + // let (actions, flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + // serialize_authorized_bundle(&bundle); + // + // let transition = UnshieldTransition::try_from_bundle( + // output_address, + // amount, + // actions, + // flags, + // value_balance, + // anchor_bytes, + // proof_bytes, + // binding_sig, + // platform_version, + // ) + // .expect("expected to create unshield transition"); + // + // tracing::debug!(amount, "Unshield transition successfully built"); + // + // Some(transition) + // } + // + // /// Build a ShieldedWithdrawal state transition (shielded pool -> core L1 address). + // /// + // /// Spends an existing note and withdraws the value to a core script. + // /// Requires notes from prior Shield or ShieldFromAssetLock transitions. + // fn create_shielded_withdrawal_transition( + // &mut self, + // _amount_range: &AmountRange, + // _rng: &mut StdRng, + // shielded_state: &mut Option, + // platform_version: &PlatformVersion, + // ) -> Option { + // let state = shielded_state.as_mut()?; + // if !state.has_spendable_notes() { + // tracing::debug!("No spendable notes available for shielded withdrawal"); + // return None; + // } + // + // let (note, merkle_path, anchor) = state.take_spendable_note()?; + // let note_value = note.value().inner(); + // + // tracing::debug!(note_value, "Building shielded withdrawal bundle"); + // + // let fvk = state.fvk.clone(); + // let ask = state.ask.clone(); + // let recipient = fvk.address_at(0u32, Scope::External); + // + // // Spend full note, output half back to pool, withdraw the other half + // let withdrawal_amount = note_value / 2; + // let change_amount = note_value - withdrawal_amount; + // + // // Build bundle: spend note -> output change (value_balance = withdrawal_amount) + // let mut builder = Builder::::new(BundleType::DEFAULT, anchor); + // builder + // .add_spend(fvk, note, merkle_path) + // .expect("expected to add spend"); + // builder + // .add_output( + // None, + // recipient, + // NoteValue::from_raw(change_amount), + // [0u8; 36], + // ) + // .expect("expected to add output"); + // + // let pk = get_proving_key(); + // let mut bundle_rng = rand::rngs::OsRng; + // let (unauthorized, _) = builder + // .build::(&mut bundle_rng) + // .expect("expected to build bundle") + // .expect("expected bundle to be present"); + // + // // ShieldedWithdrawal extra_data = output_script.as_bytes() || amount.to_le_bytes() + // let output_script = CoreScript::new_p2pkh([7u8; 20]); + // let amount = withdrawal_amount; + // let mut extra_sighash_data = output_script.as_bytes().to_vec(); + // extra_sighash_data.extend_from_slice(&amount.to_le_bytes()); + // + // let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + // let sighash = compute_platform_sighash(&bundle_commitment, &extra_sighash_data); + // let proven = unauthorized + // .create_proof(pk, &mut bundle_rng) + // .expect("expected to create proof"); + // let bundle = proven + // .apply_signatures(bundle_rng, sighash, &[ask]) + // .expect("expected to apply signatures"); + // + // let (actions, flags, value_balance, anchor_bytes, proof_bytes, binding_sig) = + // serialize_authorized_bundle(&bundle); + // + // let transition = ShieldedWithdrawalTransition::try_from_bundle( + // amount, + // actions, + // flags, + // value_balance, + // anchor_bytes, + // proof_bytes, + // binding_sig, + // 1, // core_fee_per_byte + // Pooling::Never, + // output_script, + // platform_version, + // ) + // .expect("expected to create shielded withdrawal transition"); + // + // tracing::debug!(amount, "ShieldedWithdrawal transition successfully built"); + // + // Some(transition) + // } } pub enum StrategyRandomness { diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs index d330c2f514d..2d1611cbbc8 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/address_tests.rs @@ -2205,7 +2205,9 @@ mod tests { // Now verify the proof using the FromProof trait with our test ContextProvider // This is the key test - it verifies the proof signature using the quorum public key - let verification_result = GroveTrunkQueryResult::maybe_from_proof_with_metadata::<_, _>( + let verification_result = >::maybe_from_proof_with_metadata( request, response, Network::Testnet, diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs index f48de066702..8f0537b8c14 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs @@ -6,6 +6,8 @@ mod core_update_tests; mod data_contract_history_tests; mod identity_and_document_tests; mod identity_transfer_tests; +// TODO: re-enable once OperationType shielded variants are implemented +// mod shielded_tests; mod token_tests; mod top_up_tests; mod update_identities_tests; diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/shielded_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shielded_tests.rs new file mode 100644 index 00000000000..96e0cf4c792 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shielded_tests.rs @@ -0,0 +1,664 @@ +// Feature-gated because shielded strategy tests are long-running (ZK proof generation). +// Run with: cargo test -p drive-abci --features __shielded_strategy_tests +// TODO: Add shielded variants to OperationType enum to enable these tests. +#[cfg(feature = "__shielded_strategy_tests")] +#[cfg(test)] +mod tests { + + use crate::execution::run_chain_for_strategy; + use crate::strategy::NetworkStrategy; + use dpp::dash_to_credits; + use dpp::state_transition::StateTransition; + use drive_abci::config::{ + ChainLockConfig, ExecutionConfig, InstantLockConfig, PlatformConfig, PlatformTestConfig, + ValidatorSetConfig, + }; + use drive_abci::logging::LogLevel; + use drive_abci::test::helpers::setup::TestPlatformBuilder; + use strategy_tests::frequency::Frequency; + use strategy_tests::operations::{Operation, OperationType}; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + + /// Helper: create a standard platform config for shielded tests. + fn shielded_test_config() -> PlatformConfig { + PlatformConfig { + validator_set: ValidatorSetConfig::default_100_67(), + chain_lock: ChainLockConfig::default_100_67(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + verify_sum_trees: true, + ..Default::default() + }, + block_spacing_ms: 3000, + testing_configs: PlatformTestConfig::default_minimal_verifications(), + ..Default::default() + } + } + + /// Helper: create a base NetworkStrategy with common settings for shielded tests. + fn shielded_base_strategy(operations: Vec) -> NetworkStrategy { + NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations, + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo::default(), + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 100, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: None, + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: false, + sign_instant_locks: true, + ..Default::default() + } + } + + /// Strategy test that funds addresses via asset locks and then shields funds + /// into the shielded credit pool through the multi-block execution pipeline. + /// + /// This exercises the full Shield transition lifecycle: + /// 1. Orchard bundle building (output-only, no spends) + /// 2. Halo 2 ZK proof generation (via cached ProvingKey) + /// 3. Address input witness signing + /// 4. Platform validation (structure + state + ZK proof verification) + /// 5. Storage operations (commitment tree, encrypted notes, pool balance) + /// + /// Note: The first run takes ~30s to build the ProvingKey (cached via OnceLock). + #[test] + fn run_chain_shield_transitions() { + drive_abci::logging::init_for_tests(LogLevel::Debug); + + let strategy = shielded_base_strategy(vec![ + // Fund addresses first (every block, 2-3 asset locks of 20 DASH each) + Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(20)..=dash_to_credits!(20), + ), + frequency: Frequency { + times_per_block_range: 2..4, + chance_per_block: None, + }, + }, + // Shield funds from funded addresses (1 per block, 1-5 DASH) + Operation { + op_type: OperationType::Shield(dash_to_credits!(1)..=dash_to_credits!(5)), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ]); + + let config = shielded_test_config(); + + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let outcome = + run_chain_for_strategy(&mut platform, 5, strategy, config, 15, &mut None, &mut None); + + // Count successful shield transitions across all blocks + let shield_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| matches!(st, StateTransition::Shield(_)) && result.code == 0) + .count(); + + assert!( + shield_count > 0, + "expected at least one successful shield transition across 5 blocks" + ); + + tracing::info!(shield_count, "Shield strategy test completed successfully"); + } + + /// Strategy test that shields funds directly from core asset lock proofs + /// into the shielded credit pool. + /// + /// This exercises the ShieldFromAssetLock transition lifecycle: + /// 1. Asset lock proof creation and signing + /// 2. Orchard bundle building (output-only, no spends) + /// 3. Halo 2 ZK proof generation + /// 4. ECDSA signing of the transition with asset lock private key + /// 5. Platform validation and storage + #[test] + fn run_chain_shield_from_asset_lock_transitions() { + drive_abci::logging::init_for_tests(LogLevel::Debug); + + let strategy = shielded_base_strategy(vec![ + // Shield directly from asset locks (1-2 per block, 5-10 DASH each) + Operation { + op_type: OperationType::ShieldFromAssetLock( + dash_to_credits!(5)..=dash_to_credits!(10), + ), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + ]); + + let config = shielded_test_config(); + + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let outcome = + run_chain_for_strategy(&mut platform, 5, strategy, config, 15, &mut None, &mut None); + + let shield_from_asset_lock_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| { + matches!(st, StateTransition::ShieldFromAssetLock(_)) && result.code == 0 + }) + .count(); + + assert!( + shield_from_asset_lock_count > 0, + "expected at least one successful ShieldFromAssetLock transition across 5 blocks" + ); + + tracing::info!( + shield_from_asset_lock_count, + "ShieldFromAssetLock strategy test completed successfully" + ); + } + + /// Strategy test that first shields funds, then transfers within the shielded pool. + /// + /// This exercises the ShieldedTransfer transition lifecycle: + /// 1. Shield funds first to create spendable notes + /// 2. Build spend bundles consuming previous notes + /// 3. Halo 2 ZK proof generation for spend+output + /// 4. RedPallas spend authorization signing + /// 5. Platform validation (anchor, nullifiers, ZK proof) + /// + /// The first few blocks only shield (to create spendable notes). The + /// ShieldedTransfer operations only succeed once notes become available + /// in the tracked shielded state. + #[test] + fn run_chain_shielded_transfer_transitions() { + drive_abci::logging::init_for_tests(LogLevel::Debug); + + let strategy = shielded_base_strategy(vec![ + // Fund addresses first + Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(20)..=dash_to_credits!(20), + ), + frequency: Frequency { + times_per_block_range: 2..4, + chance_per_block: None, + }, + }, + // Shield funds to create spendable notes + Operation { + op_type: OperationType::Shield(dash_to_credits!(5)..=dash_to_credits!(10)), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + // Transfer within shielded pool (will only work once notes are available) + Operation { + op_type: OperationType::ShieldedTransfer(dash_to_credits!(1)..=dash_to_credits!(5)), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ]); + + let config = shielded_test_config(); + + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let outcome = + run_chain_for_strategy(&mut platform, 8, strategy, config, 15, &mut None, &mut None); + + // Count all shielded transitions + let shield_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| matches!(st, StateTransition::Shield(_)) && result.code == 0) + .count(); + + let transfer_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| { + matches!(st, StateTransition::ShieldedTransfer(_)) && result.code == 0 + }) + .count(); + + tracing::info!( + shield_count, + transfer_count, + "ShieldedTransfer strategy test completed" + ); + + assert!( + shield_count > 0, + "expected at least one successful Shield transition" + ); + + // ShieldedTransfer may or may not succeed depending on timing (notes need to be + // available and anchors need to be in state). We just verify the test runs + // without panicking. If transfers succeed, that's a bonus. + tracing::info!( + transfer_count, + "ShieldedTransfer transitions that succeeded" + ); + } + + /// Strategy test that first shields funds, then unshields to platform addresses. + /// + /// This exercises the Unshield transition lifecycle: + /// 1. Shield funds first to create spendable notes + /// 2. Build spend bundles with extra_data binding output_address + amount + /// 3. Halo 2 ZK proof generation for spend+output + /// 4. RedPallas spend authorization signing + /// 5. Platform validation (anchor, nullifiers, ZK proof, pool balance) + #[test] + fn run_chain_unshield_transitions() { + drive_abci::logging::init_for_tests(LogLevel::Debug); + + let strategy = shielded_base_strategy(vec![ + // Fund addresses first + Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(20)..=dash_to_credits!(20), + ), + frequency: Frequency { + times_per_block_range: 2..4, + chance_per_block: None, + }, + }, + // Shield funds to create spendable notes + Operation { + op_type: OperationType::Shield(dash_to_credits!(5)..=dash_to_credits!(10)), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + // Unshield from pool to platform address + Operation { + op_type: OperationType::Unshield(dash_to_credits!(1)..=dash_to_credits!(5)), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ]); + + let config = shielded_test_config(); + + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let outcome = + run_chain_for_strategy(&mut platform, 8, strategy, config, 15, &mut None, &mut None); + + let shield_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| matches!(st, StateTransition::Shield(_)) && result.code == 0) + .count(); + + let unshield_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| matches!(st, StateTransition::Unshield(_)) && result.code == 0) + .count(); + + tracing::info!( + shield_count, + unshield_count, + "Unshield strategy test completed" + ); + + assert!( + shield_count > 0, + "expected at least one successful Shield transition" + ); + + // Like ShieldedTransfer, unshield may not succeed on every run due to + // timing constraints (notes + anchors in state). + tracing::info!(unshield_count, "Unshield transitions that succeeded"); + } + + /// Strategy test that verifies anchors are correctly recorded and indexed + /// after shielding operations that add notes to the commitment tree. + /// + /// Checks: + /// 1. Anchors tree (anchor_bytes → block_height) has entries after successful shields + /// 2. Anchors-by-height tree (block_height → anchor_bytes) has matching reverse entries + /// 3. Most recent anchor element is updated and non-zero + /// 4. Both trees are consistent (same count, matching entries) + #[test] + fn run_chain_verify_anchors_after_shielding() { + use drive::drive::shielded::paths::{ + shielded_credit_pool_anchors_by_height_path, shielded_credit_pool_anchors_path_vec, + shielded_credit_pool_path, SHIELDED_MOST_RECENT_ANCHOR_KEY, + }; + use drive::grovedb::query_result_type::QueryResultType; + use drive::grovedb::{Element, PathQuery, Query, SizedQuery}; + + drive_abci::logging::init_for_tests(LogLevel::Debug); + + let strategy = shielded_base_strategy(vec![ + // Fund addresses (every block, 2-3 asset locks of 20 DASH each) + Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(20)..=dash_to_credits!(20), + ), + frequency: Frequency { + times_per_block_range: 2..4, + chance_per_block: None, + }, + }, + // Shield funds (1 per block, 1-5 DASH) + Operation { + op_type: OperationType::Shield(dash_to_credits!(1)..=dash_to_credits!(5)), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ]); + + let config = shielded_test_config(); + + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let block_count = 5; + let outcome = run_chain_for_strategy( + &mut platform, + block_count, + strategy, + config, + 15, + &mut None, + &mut None, + ); + + // Verify at least one shield succeeded (prerequisite for anchors) + let shield_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| matches!(st, StateTransition::Shield(_)) && result.code == 0) + .count(); + assert!( + shield_count > 0, + "expected at least one successful shield transition" + ); + + let platform_state = outcome.abci_app.platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected platform version"); + let drive = &outcome.abci_app.platform.drive; + + // 1. Query all anchors from the anchors tree (anchor_bytes → block_height) + let anchors_path_query = PathQuery { + path: shielded_credit_pool_anchors_path_vec(), + query: SizedQuery { + query: Query::new_range_full(), + limit: None, + offset: None, + }, + }; + + let (anchor_results, _) = drive + .grove_get_raw_path_query( + &anchors_path_query, + None, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query anchors"); + + let anchor_entries = anchor_results.to_key_elements(); + assert!( + !anchor_entries.is_empty(), + "expected anchors to be recorded after successful shield transitions" + ); + + // Each anchor should map to a valid block height within our range + let mut anchor_to_height: Vec<(Vec, u64)> = Vec::new(); + for (anchor_key, element) in &anchor_entries { + assert_eq!(anchor_key.len(), 32, "anchor key must be 32 bytes"); + if let Element::Item(value, _) = element { + let height = u64::from_be_bytes( + value + .as_slice() + .try_into() + .expect("block height must be 8 bytes"), + ); + assert!( + height >= 1 && height <= block_count, + "anchor block height {} out of expected range [1, {}]", + height, + block_count + ); + anchor_to_height.push((anchor_key.clone(), height)); + } else { + panic!("expected Item element in anchors tree"); + } + } + + // 2. Query all entries from anchors-by-height tree (block_height → anchor_bytes) + let by_height_path: Vec> = shielded_credit_pool_anchors_by_height_path() + .iter() + .map(|p| p.to_vec()) + .collect(); + let by_height_path_query = PathQuery { + path: by_height_path, + query: SizedQuery { + query: Query::new_range_full(), + limit: None, + offset: None, + }, + }; + + let (by_height_results, _) = drive + .grove_get_raw_path_query( + &by_height_path_query, + None, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query anchors-by-height"); + + let by_height_entries = by_height_results.to_key_elements(); + + // Both trees must have the same number of entries + assert_eq!( + anchor_entries.len(), + by_height_entries.len(), + "anchors tree ({}) and anchors-by-height tree ({}) must have same entry count", + anchor_entries.len(), + by_height_entries.len() + ); + + // Verify the reverse index is consistent: for each height→anchor, the anchor→height must match + for (height_key, element) in &by_height_entries { + let height = u64::from_be_bytes( + height_key + .as_slice() + .try_into() + .expect("height key must be 8 bytes"), + ); + if let Element::Item(anchor_bytes, _) = element { + assert_eq!(anchor_bytes.len(), 32, "anchor value must be 32 bytes"); + // Find matching entry in anchor_to_height + let matching = anchor_to_height + .iter() + .find(|(a, h)| a == anchor_bytes && *h == height); + assert!( + matching.is_some(), + "anchors-by-height entry (height={}) has no matching entry in anchors tree", + height + ); + } else { + panic!("expected Item element in anchors-by-height tree"); + } + } + + // 3. Verify most recent anchor is set and non-zero + let pool_path = shielded_credit_pool_path(); + let most_recent_element = drive + .grove + .get( + &pool_path, + &[SHIELDED_MOST_RECENT_ANCHOR_KEY], + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("most recent anchor element must exist"); + + if let Element::Item(most_recent_bytes, _) = most_recent_element { + assert_eq!( + most_recent_bytes.len(), + 32, + "most recent anchor must be 32 bytes" + ); + assert_ne!( + most_recent_bytes, + vec![0u8; 32], + "most recent anchor must not be all zeros after successful shields" + ); + // Most recent anchor must be one of the recorded anchors + let is_known = anchor_to_height + .iter() + .any(|(a, _)| *a == most_recent_bytes); + assert!( + is_known, + "most recent anchor must match one of the recorded anchors" + ); + } else { + panic!("most recent anchor must be an Item element"); + } + + tracing::info!( + anchor_count = anchor_entries.len(), + shield_count, + "Anchor verification test completed successfully" + ); + } + + /// Strategy test that first shields funds, then withdraws to a core (L1) address. + /// + /// This exercises the ShieldedWithdrawal transition lifecycle: + /// 1. Shield funds first to create spendable notes + /// 2. Build spend bundles with extra_data binding output_script + amount + /// 3. Halo 2 ZK proof generation for spend+output + /// 4. RedPallas spend authorization signing + /// 5. Platform validation (anchor, nullifiers, ZK proof, pool balance, withdrawal queue) + #[test] + fn run_chain_shielded_withdrawal_transitions() { + drive_abci::logging::init_for_tests(LogLevel::Debug); + + let strategy = shielded_base_strategy(vec![ + // Fund addresses first + Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(20)..=dash_to_credits!(20), + ), + frequency: Frequency { + times_per_block_range: 2..4, + chance_per_block: None, + }, + }, + // Shield funds to create spendable notes + Operation { + op_type: OperationType::Shield(dash_to_credits!(5)..=dash_to_credits!(10)), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + // Withdraw from pool to core address + Operation { + op_type: OperationType::ShieldedWithdrawal( + dash_to_credits!(1)..=dash_to_credits!(5), + ), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ]); + + let config = shielded_test_config(); + + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let outcome = + run_chain_for_strategy(&mut platform, 8, strategy, config, 15, &mut None, &mut None); + + let shield_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| matches!(st, StateTransition::Shield(_)) && result.code == 0) + .count(); + + let withdrawal_count = outcome + .state_transition_results_per_block + .values() + .flat_map(|results| results.iter()) + .filter(|(st, result)| { + matches!(st, StateTransition::ShieldedWithdrawal(_)) && result.code == 0 + }) + .count(); + + tracing::info!( + shield_count, + withdrawal_count, + "ShieldedWithdrawal strategy test completed" + ); + + assert!( + shield_count > 0, + "expected at least one successful Shield transition" + ); + + // Like the other spend-based transitions, withdrawal may not succeed on + // every run due to timing constraints. + tracing::info!( + withdrawal_count, + "ShieldedWithdrawal transitions that succeeded" + ); + } +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index ac7d10068a1..b42bb00cc67 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -1449,12 +1449,13 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( } } StateTransitionAction::BumpAddressInputNoncesAction(_) => {} - StateTransitionAction::ShieldAction(_) => {} - StateTransitionAction::ShieldedTransferAction(_) => {} - StateTransitionAction::UnshieldAction(_) => {} - StateTransitionAction::ShieldFromAssetLockAction(_) => {} - StateTransitionAction::ShieldedWithdrawalAction(_) => {} - StateTransitionAction::PenalizeShieldedPoolAction(_) => {} + StateTransitionAction::ShieldAction(_) + | StateTransitionAction::ShieldedTransferAction(_) + | StateTransitionAction::UnshieldAction(_) + | StateTransitionAction::ShieldFromAssetLockAction(_) + | StateTransitionAction::ShieldedWithdrawalAction(_) => { + // Shielded transitions don't support proof verification yet + } } } else { // if we don't have an action this means there was a problem in the validation of the state transition diff --git a/packages/rs-drive/src/drive/initialization/v3/mod.rs b/packages/rs-drive/src/drive/initialization/v3/mod.rs index 06f4bf5727b..dc817a940b0 100644 --- a/packages/rs-drive/src/drive/initialization/v3/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v3/mod.rs @@ -100,13 +100,28 @@ impl Drive { Element::new_sum_item(0), ); - // 5. Anchors tree (NormalTree) inside pool: block_height_be → anchor_bytes + // 5. Anchors tree (NormalTree) inside pool: anchor_bytes → block_height_be batch.add_insert( shielded_credit_pool_path_vec(), vec![SHIELDED_ANCHORS_IN_POOL_KEY], Element::empty_tree(), ); + // 5b. Anchors-by-height tree (NormalTree): block_height_be → anchor_bytes + // Reverse index for pruning old anchors by height range. + batch.add_insert( + shielded_credit_pool_path_vec(), + vec![SHIELDED_ANCHORS_BY_HEIGHT_KEY], + Element::empty_tree(), + ); + + // 5c. Most recent anchor item (empty initially, set on first block with notes) + batch.add_insert( + shielded_credit_pool_path_vec(), + vec![SHIELDED_MOST_RECENT_ANCHOR_KEY], + Element::new_item(vec![0u8; 32]), + ); + // 6. Per-block nullifiers CountSumTree under shielded credit pool. // Each item is an ItemWithSumItem (serialized Vec<[u8;32]> + nullifier count as sum). batch.add_insert( diff --git a/packages/rs-drive/src/drive/shielded/estimated_costs.rs b/packages/rs-drive/src/drive/shielded/estimated_costs.rs index 87de335bfd1..581c87f4abe 100644 --- a/packages/rs-drive/src/drive/shielded/estimated_costs.rs +++ b/packages/rs-drive/src/drive/shielded/estimated_costs.rs @@ -1,6 +1,7 @@ use crate::drive::shielded::paths::{ - shielded_credit_pool_anchors_path, shielded_credit_pool_notes_path, - shielded_credit_pool_nullifiers_path, shielded_credit_pool_path, SHIELDED_NOTES_CHUNK_POWER, + shielded_credit_pool_anchors_by_height_path, shielded_credit_pool_anchors_path, + shielded_credit_pool_notes_path, shielded_credit_pool_nullifiers_path, + shielded_credit_pool_path, SHIELDED_NOTES_CHUNK_POWER, }; use crate::drive::{Drive, RootTree}; use grovedb::batch::KeyInfoPath; @@ -18,11 +19,11 @@ const AVERAGE_NOTE_VALUE_SIZE: u32 = 280; /// Size of a nullifier key (32 bytes) const NULLIFIER_KEY_SIZE: u8 = 32; -/// Size of an anchor block height key (u64 big-endian = 8 bytes) -const ANCHOR_KEY_SIZE: u8 = 8; +/// Size of an anchor key (32 bytes) +const ANCHOR_KEY_SIZE: u8 = 32; -/// Size of an anchor value (32 bytes) -const ANCHOR_VALUE_SIZE: u32 = 32; +/// Size of an anchor value (u64 big-endian block height = 8 bytes) +const ANCHOR_VALUE_SIZE: u32 = 8; impl Drive { /// Adds estimation costs for shielded pool operations. @@ -76,14 +77,15 @@ impl Drive { // Shielded credit pool: [AddressBalances, "s"] // SumTree containing: notes (CommitmentTree), permanent nullifiers (ProvableCountTree), - // total balance (SumItem), anchors (NormalTree), recent nullifiers (CountSumTree), - // compacted nullifiers (NormalTree), expiration time (NormalTree) - // 7 elements total (6 subtrees + 1 item) → balanced Merk depth = ceil(log2(8)) = 3 + // total balance (SumItem), anchors (NormalTree), anchors-by-height (NormalTree), + // recent nullifiers (CountSumTree), compacted nullifiers (NormalTree), + // expiration time (NormalTree), most recent anchor (Item) + // 9 elements total (7 subtrees + 2 items) → balanced Merk depth = ceil(log2(9)) = 4 estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_path(shielded_credit_pool_path()), EstimatedLayerInformation { tree_type: TreeType::SumTree, - estimated_layer_count: EstimatedLevel(3, false), + estimated_layer_count: EstimatedLevel(4, false), estimated_layer_sizes: Mix { subtrees_size: Some(( 1, @@ -92,12 +94,12 @@ impl Drive { big_sum_trees_weight: 0, count_trees_weight: 1, // permanent nullifiers (ProvableCountTree) count_sum_trees_weight: 1, // recent nullifiers (CountSumTree) - non_sum_trees_weight: 4, // notes (CommitmentTree), anchors, compacted nullifiers, expiration time + non_sum_trees_weight: 5, // notes (CommitmentTree), anchors, anchors-by-height, compacted nullifiers, expiration time }, None, - 6, // 6 subtrees: notes, permanent nullifiers, anchors, recent nullifiers, compacted nullifiers, expiration time + 7, // 7 subtrees: notes, permanent nullifiers, anchors, anchors-by-height, recent nullifiers, compacted nullifiers, expiration time )), - items_size: Some((1, 8, None, 1)), // 1 item: total balance + items_size: Some((1, 32, None, 2)), // 2 items: total balance (SumItem), most recent anchor (Item) references_size: None, }, }, @@ -126,7 +128,7 @@ impl Drive { ); // Anchors tree: [AddressBalances, "s", 6] - // NormalTree - stores block_height_be -> anchor_bytes + // NormalTree - stores anchor_bytes -> block_height_be estimated_costs_only_with_layer_info.insert( KeyInfoPath::from_known_path(shielded_credit_pool_anchors_path()), EstimatedLayerInformation { @@ -135,5 +137,20 @@ impl Drive { estimated_layer_sizes: AllItems(ANCHOR_KEY_SIZE, ANCHOR_VALUE_SIZE, None), }, ); + + // Anchors-by-height tree: [AddressBalances, "s", 8] + // NormalTree - stores block_height_be -> anchor_bytes (reverse index for pruning) + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(shielded_credit_pool_anchors_by_height_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(7, false), + estimated_layer_sizes: AllItems( + ANCHOR_VALUE_SIZE as u8, + ANCHOR_KEY_SIZE as u32, + None, + ), + }, + ); } } diff --git a/packages/rs-drive/src/drive/shielded/has_anchor/mod.rs b/packages/rs-drive/src/drive/shielded/has_anchor/mod.rs new file mode 100644 index 00000000000..b02ba7e5d20 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/has_anchor/mod.rs @@ -0,0 +1,42 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Checks whether a shielded pool anchor exists in the anchors tree. + /// + /// Anchors are stored as `anchor_bytes -> block_height_be` in + /// `[AddressBalances, "s", [6]]`. Uses O(1) key lookup. + /// + /// # Parameters + /// - `anchor`: The 32-byte anchor to look up + /// - `transaction`: The GroveDB transaction + /// - `drive_operations`: A vector to collect the costs of operations + /// - `platform_version`: The platform version for dispatch + /// + /// # Returns + /// `Ok(true)` if the anchor exists, `Ok(false)` otherwise. + pub fn has_shielded_anchor( + &self, + anchor: &[u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version.drive.methods.shielded.has_anchor { + 0 => { + self.has_shielded_anchor_v0(anchor, transaction, drive_operations, platform_version) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "has_shielded_anchor".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} 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 new file mode 100644 index 00000000000..4096a7c893a --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/has_anchor/v0/mod.rs @@ -0,0 +1,32 @@ +use crate::drive::shielded::paths::shielded_credit_pool_anchors_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Version 0 implementation of checking whether a shielded anchor exists. + /// + /// Performs an O(1) key lookup in the anchors tree at + /// `[AddressBalances, "s", [6]]`. + pub(in crate::drive) fn has_shielded_anchor_v0( + &self, + anchor: &[u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let anchors_path = shielded_credit_pool_anchors_path(); + + self.grove_has_raw( + (&anchors_path).into(), + anchor, + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/shielded/has_nullifier/mod.rs b/packages/rs-drive/src/drive/shielded/has_nullifier/mod.rs new file mode 100644 index 00000000000..ba950b2ce4d --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/has_nullifier/mod.rs @@ -0,0 +1,40 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Checks whether a nullifier has already been spent in the shielded pool. + /// + /// Nullifiers are stored in the nullifiers tree at + /// `[AddressBalances, "s", [2]]`. Uses O(1) key lookup. + /// + /// # Parameters + /// - `nullifier`: The 32-byte nullifier to look up + /// - `transaction`: The GroveDB transaction + /// - `drive_operations`: A vector to collect the costs of operations + /// - `platform_version`: The platform version for dispatch + /// + /// # Returns + /// `Ok(true)` if the nullifier exists (already spent), `Ok(false)` otherwise. + pub fn has_nullifier( + &self, + nullifier: &[u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version.drive.methods.shielded.has_nullifier { + 0 => self.has_nullifier_v0(nullifier, transaction, drive_operations, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "has_nullifier".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} 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 new file mode 100644 index 00000000000..e25dbbd6d20 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/has_nullifier/v0/mod.rs @@ -0,0 +1,32 @@ +use crate::drive::shielded::paths::shielded_credit_pool_nullifiers_path; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Version 0 implementation of checking whether a nullifier exists. + /// + /// Performs an O(1) key lookup in the nullifiers tree at + /// `[AddressBalances, "s", [2]]`. + pub(in crate::drive) fn has_nullifier_v0( + &self, + nullifier: &[u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let nullifiers_path = shielded_credit_pool_nullifiers_path(); + + self.grove_has_raw( + (&nullifiers_path).into(), + nullifier, + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/shielded/mod.rs b/packages/rs-drive/src/drive/shielded/mod.rs index a9cd1d6d086..5efc95b4326 100644 --- a/packages/rs-drive/src/drive/shielded/mod.rs +++ b/packages/rs-drive/src/drive/shielded/mod.rs @@ -18,6 +18,30 @@ mod insert_nullifiers; #[cfg(feature = "server")] mod update_total_balance; +/// Record the shielded pool anchor if the commitment tree changed this block +#[cfg(feature = "server")] +mod record_anchor_if_changed; + +/// Prune shielded pool anchors older than a given cutoff height +#[cfg(feature = "server")] +mod prune_anchors; + +/// Check whether a shielded pool anchor exists +#[cfg(feature = "server")] +mod has_anchor; + +/// Check whether a nullifier has already been spent +#[cfg(feature = "server")] +mod has_nullifier; + +/// Read the shielded pool total balance +#[cfg(feature = "server")] +mod read_total_balance; + +/// Count the notes in the shielded pool commitment tree +#[cfg(feature = "server")] +mod notes_count; + /// Prove methods for shielded pool queries #[cfg(feature = "server")] pub mod prove; diff --git a/packages/rs-drive/src/drive/shielded/notes_count/mod.rs b/packages/rs-drive/src/drive/shielded/notes_count/mod.rs new file mode 100644 index 00000000000..a05317c2558 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/notes_count/mod.rs @@ -0,0 +1,35 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Returns the total number of notes in the shielded pool commitment tree. + /// + /// # Parameters + /// - `transaction`: The GroveDB transaction + /// - `drive_operations`: A vector to collect the costs of operations + /// - `platform_version`: The platform version for dispatch + /// + /// # Returns + /// The number of notes currently in the commitment tree. + pub fn shielded_pool_notes_count( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version.drive.methods.shielded.notes_count { + 0 => self.shielded_pool_notes_count_v0(transaction, drive_operations, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "shielded_pool_notes_count".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} 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 new file mode 100644 index 00000000000..e6648466682 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/notes_count/v0/mod.rs @@ -0,0 +1,28 @@ +use crate::drive::shielded::paths::{shielded_credit_pool_path, SHIELDED_NOTES_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Version 0 implementation of counting shielded pool notes. + /// + /// Returns the total number of items in the CommitmentTree at + /// `[AddressBalances, "s", [1]]`. + pub(in crate::drive) fn shielded_pool_notes_count_v0( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let pool_path = shielded_credit_pool_path(); + self.grove_commitment_tree_count( + (&pool_path).into(), + &[SHIELDED_NOTES_KEY], + transaction, + drive_operations, + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/shielded/paths.rs b/packages/rs-drive/src/drive/shielded/paths.rs index 8698b2dd3b1..35d1142e185 100644 --- a/packages/rs-drive/src/drive/shielded/paths.rs +++ b/packages/rs-drive/src/drive/shielded/paths.rs @@ -15,9 +15,16 @@ pub const SHIELDED_NULLIFIERS_KEY: u8 = 2; /// Key for the total balance sum item inside a shielded pool pub const SHIELDED_TOTAL_BALANCE_KEY: u8 = 5; -/// Key for the anchors tree inside a shielded pool +/// Key for the anchors tree inside a shielded pool (anchor_bytes → block_height_be) pub const SHIELDED_ANCHORS_IN_POOL_KEY: u8 = 6; +/// Key for the most recent anchor item inside a shielded pool +pub const SHIELDED_MOST_RECENT_ANCHOR_KEY: u8 = 7; + +/// Key for the anchors-by-height tree inside a shielded pool (block_height_be → anchor_bytes) +/// Reverse index of SHIELDED_ANCHORS_IN_POOL_KEY, used for pruning old anchors by height range. +pub const SHIELDED_ANCHORS_BY_HEIGHT_KEY: u8 = 8; + /// Chunk power for the notes CommitmentTree (2^11 = 2048 items per chunk) pub const SHIELDED_NOTES_CHUNK_POWER: u8 = 11; @@ -91,6 +98,24 @@ pub fn shielded_credit_pool_anchors_path_vec() -> Vec> { ] } +/// Path to the anchors-by-height tree: [AddressBalances, "s", [8]] +pub fn shielded_credit_pool_anchors_by_height_path() -> [&'static [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::AddressBalances), + SHIELDED_CREDIT_POOL_KEY, + &[SHIELDED_ANCHORS_BY_HEIGHT_KEY], + ] +} + +/// Path to the anchors-by-height tree as a vec: [AddressBalances, "s", [8]] +pub fn shielded_credit_pool_anchors_by_height_path_vec() -> Vec> { + vec![ + vec![RootTree::AddressBalances as u8], + SHIELDED_CREDIT_POOL_KEY.to_vec(), + vec![SHIELDED_ANCHORS_BY_HEIGHT_KEY], + ] +} + /// Resolves the nullifiers path based on pool type. /// /// Pool types: diff --git a/packages/rs-drive/src/drive/shielded/prune_anchors/mod.rs b/packages/rs-drive/src/drive/shielded/prune_anchors/mod.rs new file mode 100644 index 00000000000..8f43416d190 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/prune_anchors/mod.rs @@ -0,0 +1,39 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::Transaction; + +impl Drive { + /// Prunes shielded pool anchors older than the given cutoff height. + /// + /// Queries the anchors-by-height tree for all entries with + /// `block_height < cutoff_height`, then deletes the corresponding entries + /// from both the anchors-by-height tree (`block_height -> anchor_bytes`) + /// and the primary anchors tree (`anchor_bytes -> block_height`). + /// + /// The caller is responsible for determining whether pruning should happen + /// (interval checks, retention depth, etc.). + /// + /// # Parameters + /// - `cutoff_height`: All anchors recorded at heights strictly below this are pruned + /// - `transaction`: The GroveDB transaction + /// - `platform_version`: The platform version for dispatch + pub fn prune_shielded_pool_anchors( + &self, + cutoff_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version.drive.methods.shielded.prune_anchors { + 0 => self.prune_shielded_pool_anchors_v0(cutoff_height, transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prune_shielded_pool_anchors".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} 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 new file mode 100644 index 00000000000..695074e9093 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/prune_anchors/v0/mod.rs @@ -0,0 +1,84 @@ +use crate::drive::shielded::paths::{ + shielded_credit_pool_anchors_by_height_path, shielded_credit_pool_anchors_path, +}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, QueryItem, SizedQuery, Transaction}; + +impl Drive { + /// Version 0 implementation of pruning shielded pool anchors. + /// + /// Queries the anchors-by-height tree for all entries with + /// `block_height < cutoff_height`, then deletes those entries from both + /// the anchors-by-height tree and the primary anchors tree. + pub(in crate::drive) fn prune_shielded_pool_anchors_v0( + &self, + cutoff_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let grove_version = &platform_version.drive.grove_version; + + // Query anchors-by-height for all entries with height < cutoff (exclusive) + let by_height_path = shielded_credit_pool_anchors_by_height_path(); + let mut query = Query::new(); + query.insert_item(QueryItem::RangeTo(..cutoff_height.to_be_bytes().to_vec())); + + let path_query = PathQuery { + path: by_height_path.iter().map(|p| p.to_vec()).collect(), + query: SizedQuery { + query, + limit: None, + offset: None, + }, + }; + + let (results, _) = self.grove_get_raw_path_query( + &path_query, + Some(transaction), + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )?; + + let entries = results.to_key_elements(); + if entries.is_empty() { + return Ok(()); + } + + let anchors_path = shielded_credit_pool_anchors_path(); + + for (height_key, element) in entries { + // Extract anchor_bytes from the element value + if let Element::Item(anchor_bytes, _) = element { + // Delete from anchors tree (anchor_bytes -> block_height) + self.grove + .delete( + &anchors_path, + &anchor_bytes, + None, + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from)?; + } + + // Delete from anchors-by-height tree (block_height -> anchor_bytes) + self.grove + .delete( + &by_height_path, + &height_key, + None, + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from)?; + } + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/shielded/read_total_balance/mod.rs b/packages/rs-drive/src/drive/shielded/read_total_balance/mod.rs new file mode 100644 index 00000000000..915baea2f2e --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/read_total_balance/mod.rs @@ -0,0 +1,42 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Reads the current shielded pool total balance from GroveDB. + /// + /// Returns 0 if the balance key does not exist yet. + /// + /// # Parameters + /// - `transaction`: The GroveDB transaction + /// - `drive_operations`: A vector to collect the costs of operations + /// - `platform_version`: The platform version for dispatch + /// + /// # Returns + /// The current total balance of the shielded pool in credits. + pub fn read_shielded_pool_total_balance( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version.drive.methods.shielded.read_total_balance { + 0 => self.read_shielded_pool_total_balance_v0( + transaction, + drive_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "read_shielded_pool_total_balance".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} 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 new file mode 100644 index 00000000000..04159ac48f2 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/read_total_balance/v0/mod.rs @@ -0,0 +1,33 @@ +use crate::drive::shielded::paths::{shielded_credit_pool_path, SHIELDED_TOTAL_BALANCE_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Version 0 implementation of reading the shielded pool total balance. + /// + /// Reads the total balance from `[AddressBalances, "s", [5]]`. + /// Returns 0 if the key does not exist yet. + pub(in crate::drive) fn read_shielded_pool_total_balance_v0( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let pool_path = shielded_credit_pool_path(); + Ok(self + .grove_get_raw_value_u64_from_encoded_var_vec( + (&pool_path).into(), + &[SHIELDED_TOTAL_BALANCE_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )? + .unwrap_or(0)) + } +} diff --git a/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/mod.rs b/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/mod.rs new file mode 100644 index 00000000000..77d5b803d5a --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/mod.rs @@ -0,0 +1,46 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::Transaction; + +impl Drive { + /// Records the current shielded pool anchor if the commitment tree changed + /// this block. + /// + /// Reads the current Sinsemilla anchor from the CommitmentTree, compares it + /// to the most recent stored anchor, and if different (and non-zero) writes + /// entries to the anchors tree, anchors-by-height tree, and updates the + /// most recent anchor item. + /// + /// # Parameters + /// - `block_height`: The current block height + /// - `transaction`: The GroveDB transaction + /// - `platform_version`: The platform version for dispatch + pub fn record_shielded_pool_anchor_if_changed( + &self, + block_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .shielded + .record_anchor_if_changed + { + 0 => self.record_shielded_pool_anchor_if_changed_v0( + block_height, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "record_shielded_pool_anchor_if_changed".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} 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 new file mode 100644 index 00000000000..4d980651e45 --- /dev/null +++ b/packages/rs-drive/src/drive/shielded/record_anchor_if_changed/v0/mod.rs @@ -0,0 +1,116 @@ +use crate::drive::shielded::paths::{ + shielded_credit_pool_anchors_by_height_path, shielded_credit_pool_anchors_path, + shielded_credit_pool_path, SHIELDED_MOST_RECENT_ANCHOR_KEY, SHIELDED_NOTES_KEY, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::{Element, Transaction}; + +impl Drive { + /// Version 0 implementation of recording the shielded pool anchor. + /// + /// Reads the current Sinsemilla anchor from the CommitmentTree at + /// `[AddressBalances, "s", [1]]`. If it differs from the most recent + /// anchor (stored at `[AddressBalances, "s", [7]]`), inserts: + /// - `anchor_bytes -> block_height.to_be_bytes()` into anchors tree `[..., [6]]` + /// - `block_height.to_be_bytes() -> anchor_bytes` into anchors-by-height tree `[..., [8]]` + /// - Updates the most recent anchor item + pub(in crate::drive) fn record_shielded_pool_anchor_if_changed_v0( + &self, + block_height: u64, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let grove_version = &platform_version.drive.grove_version; + let pool_path = shielded_credit_pool_path(); + + // 1. Read current anchor from CommitmentTree + let current_anchor = self + .grove + .commitment_tree_anchor( + &pool_path, + &[SHIELDED_NOTES_KEY], + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from)?; + + let current_anchor_bytes: [u8; 32] = current_anchor.to_bytes(); + + // 2. Read most recent anchor from the dedicated element + let most_recent_anchor: [u8; 32] = self + .grove + .get( + &pool_path, + &[SHIELDED_MOST_RECENT_ANCHOR_KEY], + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from) + .and_then(|element| { + if let Element::Item(value, _) = element { + value.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedElementType( + "most recent anchor is not 32 bytes", + )) + }) + } else { + Ok([0u8; 32]) + } + })?; + + // 3. Only store if different (skip zero anchor from empty tree) + let should_store = + current_anchor_bytes != most_recent_anchor && current_anchor_bytes != [0u8; 32]; + + if should_store { + let anchors_path = shielded_credit_pool_anchors_path(); + + // Insert anchor_bytes -> block_height into the anchors tree + self.grove + .insert( + &anchors_path, + ¤t_anchor_bytes, + Element::new_item(block_height.to_be_bytes().to_vec()), + None, + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from)?; + + // Insert block_height -> anchor_bytes into the anchors-by-height tree (for pruning) + let anchors_by_height_path = shielded_credit_pool_anchors_by_height_path(); + self.grove + .insert( + &anchors_by_height_path, + &block_height.to_be_bytes(), + Element::new_item(current_anchor_bytes.to_vec()), + None, + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from)?; + + // Update the most recent anchor + self.grove + .insert( + &pool_path, + &[SHIELDED_MOST_RECENT_ANCHOR_KEY], + Element::new_item(current_anchor_bytes.to_vec()), + None, + Some(transaction), + grove_version, + ) + .unwrap() + .map_err(Error::from)?; + } + + Ok(()) + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index dbfa6c50e04..1ed7ba86664 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs @@ -123,10 +123,6 @@ impl DriveHighLevelOperationConverter for StateTransitionAction { StateTransitionAction::ShieldedWithdrawalAction(shielded_withdrawal_action) => { shielded_withdrawal_action.into_high_level_drive_operations(epoch, platform_version) } - StateTransitionAction::PenalizeShieldedPoolAction(penalize_shielded_pool_action) => { - penalize_shielded_pool_action - .into_high_level_drive_operations(epoch, platform_version) - } } } } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/mod.rs index f3fe110aac1..84721e56f9d 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/mod.rs @@ -2,4 +2,3 @@ mod bump_address_input_nonces; mod bump_identity_data_contract_nonce; mod bump_identity_nonce; mod partially_use_asset_lock; -mod penalize_shielded_pool; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/penalize_shielded_pool.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/penalize_shielded_pool.rs deleted file mode 100644 index 29a3a9d1437..00000000000 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/system/penalize_shielded_pool.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::error::drive::DriveError; -use crate::error::Error; -use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; -use crate::state_transition_action::system::penalize_shielded_pool_action::PenalizeShieldedPoolAction; -use crate::util::batch::drive_op_batch::ShieldedPoolOperationType; -use crate::util::batch::DriveOperation; -use dpp::block::epoch::Epoch; -use dpp::version::PlatformVersion; - -impl DriveHighLevelOperationConverter for PenalizeShieldedPoolAction { - fn into_high_level_drive_operations<'a>( - self, - _epoch: &Epoch, - _platform_version: &PlatformVersion, - ) -> Result>, Error> { - match self { - PenalizeShieldedPoolAction::V0(v0) => { - let mut ops: Vec> = Vec::new(); - - // 1. Record nullifiers as spent (prevents replaying the same invalid proof) - if !v0.nullifiers.is_empty() { - ops.push(DriveOperation::ShieldedPoolOperation( - ShieldedPoolOperationType::InsertNullifiers { - nullifiers: v0.nullifiers, - }, - )); - } - - // 2. Deduct penalty from pool total balance - let new_total_balance = v0 - .current_total_balance - .checked_sub(v0.penalty_amount) - .ok_or_else(|| { - Error::Drive(DriveError::CorruptedDriveState( - "shielded pool total balance underflow when subtracting penalty_amount" - .to_string(), - )) - })?; - ops.push(DriveOperation::ShieldedPoolOperation( - ShieldedPoolOperationType::UpdateTotalBalance { new_total_balance }, - )); - - Ok(ops) - } - } - } -} diff --git a/packages/rs-drive/src/state_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/mod.rs index 074b899e1e4..e14aba510c7 100644 --- a/packages/rs-drive/src/state_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/mod.rs @@ -46,7 +46,6 @@ use crate::state_transition_action::system::bump_identity_nonce_action::{ use crate::state_transition_action::system::partially_use_asset_lock_action::{ PartiallyUseAssetLockAction, PartiallyUseAssetLockActionAccessorsV0, }; -use crate::state_transition_action::system::penalize_shielded_pool_action::PenalizeShieldedPoolAction; use derive_more::From; use dpp::prelude::UserFeeIncrease; @@ -108,8 +107,6 @@ pub enum StateTransitionAction { ShieldFromAssetLockAction(ShieldFromAssetLockTransitionAction), /// shielded withdrawal (shielded pool -> L1 core address) ShieldedWithdrawalAction(ShieldedWithdrawalTransitionAction), - /// penalize shielded pool for invalid ZK proof - PenalizeShieldedPoolAction(PenalizeShieldedPoolAction), } impl StateTransitionAction { @@ -168,9 +165,6 @@ impl StateTransitionAction { StateTransitionAction::ShieldedWithdrawalAction(_) => { UserFeeIncrease::default() // 0 (fee is locked by Orchard binding signature) } - StateTransitionAction::PenalizeShieldedPoolAction(_) => { - UserFeeIncrease::default() // 0 (no user fee increase for penalty actions) - } } } } diff --git a/packages/rs-drive/src/state_transition_action/system/mod.rs b/packages/rs-drive/src/state_transition_action/system/mod.rs index 4bd2d5bde80..debd41e2d57 100644 --- a/packages/rs-drive/src/state_transition_action/system/mod.rs +++ b/packages/rs-drive/src/state_transition_action/system/mod.rs @@ -9,6 +9,3 @@ pub mod partially_use_asset_lock_action; /// bump address input nonce action pub mod bump_address_input_nonces_action; - -/// penalize shielded pool action -pub mod penalize_shielded_pool_action; diff --git a/packages/rs-drive/src/state_transition_action/system/penalize_shielded_pool_action/mod.rs b/packages/rs-drive/src/state_transition_action/system/penalize_shielded_pool_action/mod.rs deleted file mode 100644 index a44aa21d4df..00000000000 --- a/packages/rs-drive/src/state_transition_action/system/penalize_shielded_pool_action/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -/// Penalize the shielded pool when proof verification fails for pool-spending transitions -pub mod v0; - -use derive_more::From; -use v0::PenalizeShieldedPoolActionV0; - -/// Action to deduct a penalty from the shielded pool and record nullifiers as spent -#[derive(Debug, Clone, From)] -pub enum PenalizeShieldedPoolAction { - /// V0 - V0(PenalizeShieldedPoolActionV0), -} - -/// Accessors for PenalizeShieldedPoolAction -pub trait PenalizeShieldedPoolActionAccessorsV0 { - /// The penalty amount to deduct from the pool - fn penalty_amount(&self) -> u64; - /// The nullifiers to record as spent (prevents replay) - fn nullifiers(&self) -> &[[u8; 32]]; - /// Current total pool balance - fn current_total_balance(&self) -> u64; -} - -impl PenalizeShieldedPoolActionAccessorsV0 for PenalizeShieldedPoolAction { - fn penalty_amount(&self) -> u64 { - match self { - PenalizeShieldedPoolAction::V0(v0) => v0.penalty_amount, - } - } - fn nullifiers(&self) -> &[[u8; 32]] { - match self { - PenalizeShieldedPoolAction::V0(v0) => &v0.nullifiers, - } - } - fn current_total_balance(&self) -> u64 { - match self { - PenalizeShieldedPoolAction::V0(v0) => v0.current_total_balance, - } - } -} diff --git a/packages/rs-drive/src/state_transition_action/system/penalize_shielded_pool_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/system/penalize_shielded_pool_action/v0/mod.rs deleted file mode 100644 index 7497050c7be..00000000000 --- a/packages/rs-drive/src/state_transition_action/system/penalize_shielded_pool_action/v0/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -use dpp::fee::Credits; - -/// V0 implementation of penalize shielded pool action -#[derive(Debug, Clone)] -pub struct PenalizeShieldedPoolActionV0 { - /// The penalty amount to deduct from the pool - pub penalty_amount: Credits, - /// Nullifiers to record as spent (prevents exact replay of the same invalid proof) - pub nullifiers: Vec<[u8; 32]>, - /// The current total balance of the pool before penalty - pub current_total_balance: Credits, -} diff --git a/packages/rs-drive/src/verify/shielded/verify_shielded_anchors/v0/mod.rs b/packages/rs-drive/src/verify/shielded/verify_shielded_anchors/v0/mod.rs index a2f08d7c83e..cc4ca7b33ff 100644 --- a/packages/rs-drive/src/verify/shielded/verify_shielded_anchors/v0/mod.rs +++ b/packages/rs-drive/src/verify/shielded/verify_shielded_anchors/v0/mod.rs @@ -3,7 +3,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::verify::RootHash; -use grovedb::{Element, GroveDb, PathQuery, Query, SizedQuery}; +use grovedb::{GroveDb, PathQuery, Query, SizedQuery}; use platform_version::version::PlatformVersion; impl Drive { @@ -27,23 +27,16 @@ impl Drive { GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version)? }; + // Anchors are stored as anchor_bytes (key) → block_height_be (value) let mut anchors = Vec::with_capacity(proved_key_values.len()); - for (_, _key, maybe_element) in proved_key_values { - match maybe_element { - Some(Element::Item(value, _)) => { - let anchor: [u8; 32] = value.try_into().map_err(|_v: Vec| { - Error::Drive(DriveError::CorruptedElementType( - "anchor value is not 32 bytes", - )) - })?; - anchors.push(anchor); - } - Some(_) => { - return Err(Error::Drive(DriveError::CorruptedElementType( - "expected Item element for anchor, got different element type", - ))); - } - None => {} + for (_, key, maybe_element) in proved_key_values { + if maybe_element.is_some() { + let anchor: [u8; 32] = key.try_into().map_err(|_v: Vec| { + Error::Drive(DriveError::CorruptedElementType( + "anchor key is not 32 bytes", + )) + })?; + anchors.push(anchor); } } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index e6b418f8d44..788898decf1 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -135,6 +135,7 @@ pub struct DriveAbciBlockEndMethodVersions { pub should_checkpoint: OptionalFeatureVersion, pub update_checkpoints: OptionalFeatureVersion, pub record_shielded_pool_anchor: OptionalFeatureVersion, + pub prune_shielded_pool_anchors: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs index b3d426cc8ff..c23a4c3a972 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs @@ -124,6 +124,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V1: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs index 077f5a7eb4e..55d480292c2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs @@ -125,6 +125,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V2: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs index 74f40dfe23c..c4d0ffa15e5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs @@ -124,6 +124,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V3: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs index ae7233761b9..bafa86a8534 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs @@ -124,6 +124,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V4: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs index ea35cd1fefb..778c6445530 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs @@ -128,6 +128,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V5: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs index 0e8a9e55068..de942d605ca 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs @@ -126,6 +126,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V6: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs index 72ce4a00251..95daf62b011 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs @@ -125,6 +125,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V7: DriveAbciMethodVersions = DriveAbciMeth should_checkpoint: Some(0), update_checkpoints: Some(0), record_shielded_pool_anchor: Some(0), + prune_shielded_pool_anchors: Some(0), }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 24e6f1cf8cc..58e63961909 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -31,6 +31,14 @@ pub struct DriveAbciValidationConstants { /// transitions (Unshield, ShieldedWithdrawal) are allowed. This ensures a /// sufficient anonymity set before funds can leave the pool. pub minimum_pool_notes_for_outgoing: u64, + /// Number of blocks of anchors to retain. Anchors older than this are + /// pruned at the end of each block. Clients must use an anchor no older + /// than this many blocks when building shielded transactions. + pub shielded_anchor_retention_blocks: u64, + /// Anchor pruning is only performed every N blocks to avoid unnecessary + /// GroveDB work on every block. Must evenly divide + /// `shielded_anchor_retention_blocks`. + pub shielded_anchor_pruning_interval: u64, /// Per-bundle fee (in credits) for Halo 2 ZK proof verification. /// Benchmarked at ~30x per-action signature verification cost. pub shielded_proof_verification_fee: u64, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 45dce6261b5..af26fae4cf0 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -260,6 +260,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 4e6dbad38e7..0991f5d79ab 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -260,6 +260,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 2d72c6cc872..9d62d308b13 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -260,6 +260,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index 3af5abca025..4e631bc9c37 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -263,6 +263,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index 8d207dd4a26..e19de80d669 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -264,6 +264,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index e6851d78904..bea326d225b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -267,6 +267,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index c8900b408b6..5549f4e7ed7 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -261,6 +261,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index a9936441ff8..db9c4505047 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -230,7 +230,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = transform_into_action: 0, }, shield_from_asset_lock_state_transition: DriveAbciStateTransitionValidationVersion { - basic_structure: None, + basic_structure: Some(0), advanced_structure: None, identity_signatures: None, nonce: None, @@ -238,7 +238,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = transform_into_action: 0, }, shielded_withdrawal_state_transition: DriveAbciStateTransitionValidationVersion { - basic_structure: None, + basic_structure: Some(0), advanced_structure: None, identity_signatures: None, nonce: None, @@ -265,6 +265,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = maximum_vote_polls_to_process: 2, maximum_contenders_to_consider: 100, minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, shielded_proof_verification_fee: 100_000_000, shielded_per_action_processing_fee: 3_000_000, }, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs index b21a46b743f..c249f2981ea 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_group_method_versions/mod.rs @@ -69,6 +69,12 @@ pub struct DriveShieldedMethodVersions { pub insert_note: FeatureVersion, pub insert_nullifiers: FeatureVersion, pub update_total_balance: FeatureVersion, + pub record_anchor_if_changed: FeatureVersion, + pub prune_anchors: FeatureVersion, + pub has_anchor: FeatureVersion, + pub has_nullifier: FeatureVersion, + pub read_total_balance: FeatureVersion, + pub notes_count: FeatureVersion, pub prove_nullifiers_trunk_query: FeatureVersion, pub prove_nullifiers_branch_query: FeatureVersion, pub nullifiers_query_min_depth: u8, diff --git a/packages/rs-platform-version/src/version/drive_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/v1.rs index 72610e1441b..34a7e59a03d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v1.rs @@ -108,6 +108,12 @@ pub const DRIVE_VERSION_V1: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/drive_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/v2.rs index 2378f4b410e..966b72fc36a 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v2.rs @@ -108,6 +108,12 @@ pub const DRIVE_VERSION_V2: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/drive_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/v3.rs index 598509389ad..6370819f99a 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v3.rs @@ -108,6 +108,12 @@ pub const DRIVE_VERSION_V3: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/drive_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/v4.rs index 6399cf5d8ff..6834ab8ddd9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v4.rs @@ -108,6 +108,12 @@ pub const DRIVE_VERSION_V4: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/drive_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/v5.rs index 06deea97c6a..1acc326096e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v5.rs @@ -110,6 +110,12 @@ pub const DRIVE_VERSION_V5: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/drive_versions/v6.rs b/packages/rs-platform-version/src/version/drive_versions/v6.rs index 7fb34d75aa7..c74386d5d62 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v6.rs @@ -112,6 +112,12 @@ pub const DRIVE_VERSION_V6: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/drive_versions/v7.rs b/packages/rs-platform-version/src/version/drive_versions/v7.rs index b64cbf2a519..50ec75a550d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v7.rs @@ -110,6 +110,12 @@ pub const DRIVE_VERSION_V7: DriveVersion = DriveVersion { insert_note: 0, insert_nullifiers: 0, update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, prove_nullifiers_trunk_query: 0, prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 40292137b97..6ffaa28737c 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -150,6 +150,12 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { prove_nullifiers_branch_query: 0, nullifiers_query_min_depth: 6, nullifiers_query_max_depth: 10, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, }, saved_block_transactions: DriveSavedBlockTransactionsMethodVersions { store_address_balances: 0, fetch_address_balances: 0, compact_address_balances: 0, cleanup_expired_address_balances: 0, max_blocks_before_compaction: 64, max_addresses_before_compaction: 2048, store_nullifiers: 0, fetch_nullifiers: 0, compact_nullifiers: 0, cleanup_expired_nullifier_compactions: 0, max_blocks_before_nullifier_compaction: 64, max_nullifiers_before_compaction: 2048 }, }, diff --git a/packages/rs-platform-version/src/version/mocks/v3_test.rs b/packages/rs-platform-version/src/version/mocks/v3_test.rs index 4cfc803e1ff..6e7318c7126 100644 --- a/packages/rs-platform-version/src/version/mocks/v3_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v3_test.rs @@ -159,6 +159,7 @@ pub const TEST_PLATFORM_V3: PlatformVersion = PlatformVersion { should_checkpoint: None, update_checkpoints: None, record_shielded_pool_anchor: None, + prune_shielded_pool_anchors: None, }, platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { fetch_platform_state: 0, diff --git a/packages/wasm-dpp2/src/platform_address/transitions/address_credit_withdrawal_transition.rs b/packages/wasm-dpp2/src/platform_address/transitions/address_credit_withdrawal_transition.rs index e3ffb7cb627..d86f3da668d 100644 --- a/packages/wasm-dpp2/src/platform_address/transitions/address_credit_withdrawal_transition.rs +++ b/packages/wasm-dpp2/src/platform_address/transitions/address_credit_withdrawal_transition.rs @@ -1,7 +1,7 @@ use crate::core::core_script::CoreScriptWasm; use crate::error::{WasmDppError, WasmDppResult}; use crate::identity::transitions::pooling::{PoolingLikeJs, PoolingWasm}; -use crate::impl_wasm_conversions; +use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; use crate::platform_address::{ PlatformAddressInputWasm, PlatformAddressOutputWasm, fee_strategy_from_js_options, @@ -291,7 +291,7 @@ impl AddressCreditWithdrawalTransitionWasm { } } -impl_wasm_conversions!( +impl_wasm_conversions_serde!( AddressCreditWithdrawalTransitionWasm, AddressCreditWithdrawalTransition, AddressCreditWithdrawalTransitionObjectJs, diff --git a/packages/wasm-dpp2/src/platform_address/transitions/address_funding_from_asset_lock_transition.rs b/packages/wasm-dpp2/src/platform_address/transitions/address_funding_from_asset_lock_transition.rs index 2d7db57b191..0415ba6d967 100644 --- a/packages/wasm-dpp2/src/platform_address/transitions/address_funding_from_asset_lock_transition.rs +++ b/packages/wasm-dpp2/src/platform_address/transitions/address_funding_from_asset_lock_transition.rs @@ -1,6 +1,6 @@ use crate::asset_lock_proof::AssetLockProofWasm; use crate::error::{WasmDppError, WasmDppResult}; -use crate::impl_wasm_conversions; +use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; use crate::platform_address::{ PlatformAddressInputWasm, PlatformAddressOutputWasm, fee_strategy_from_js_options, @@ -234,7 +234,7 @@ impl AddressFundingFromAssetLockTransitionWasm { } } -impl_wasm_conversions!( +impl_wasm_conversions_serde!( AddressFundingFromAssetLockTransitionWasm, AddressFundingFromAssetLockTransition, AddressFundingFromAssetLockTransitionObjectJs, diff --git a/packages/wasm-dpp2/src/platform_address/transitions/address_funds_transfer_transition.rs b/packages/wasm-dpp2/src/platform_address/transitions/address_funds_transfer_transition.rs index df6418573c0..03d12dd6c26 100644 --- a/packages/wasm-dpp2/src/platform_address/transitions/address_funds_transfer_transition.rs +++ b/packages/wasm-dpp2/src/platform_address/transitions/address_funds_transfer_transition.rs @@ -1,5 +1,5 @@ use crate::error::{WasmDppError, WasmDppResult}; -use crate::impl_wasm_conversions; +use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; use crate::platform_address::{ PlatformAddressInputWasm, PlatformAddressOutputWasm, fee_strategy_from_js_options, @@ -216,7 +216,7 @@ impl AddressFundsTransferTransitionWasm { } } -impl_wasm_conversions!( +impl_wasm_conversions_serde!( AddressFundsTransferTransitionWasm, AddressFundsTransferTransition, AddressFundsTransferTransitionObjectJs, diff --git a/packages/wasm-dpp2/src/platform_address/transitions/identity_create_from_addresses_transition.rs b/packages/wasm-dpp2/src/platform_address/transitions/identity_create_from_addresses_transition.rs index c6493616008..67976d670be 100644 --- a/packages/wasm-dpp2/src/platform_address/transitions/identity_create_from_addresses_transition.rs +++ b/packages/wasm-dpp2/src/platform_address/transitions/identity_create_from_addresses_transition.rs @@ -1,6 +1,6 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identity::transitions::public_key_in_creation::IdentityPublicKeyInCreationWasm; -use crate::impl_wasm_conversions; +use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; use crate::platform_address::{ PlatformAddressInputWasm, PlatformAddressOutputWasm, fee_strategy_from_js_options, @@ -257,7 +257,7 @@ impl IdentityCreateFromAddressesTransitionWasm { } } -impl_wasm_conversions!( +impl_wasm_conversions_serde!( IdentityCreateFromAddressesTransitionWasm, IdentityCreateFromAddressesTransition, IdentityCreateFromAddressesTransitionObjectJs, diff --git a/packages/wasm-dpp2/src/platform_address/transitions/identity_credit_transfer_to_addresses_transition.rs b/packages/wasm-dpp2/src/platform_address/transitions/identity_credit_transfer_to_addresses_transition.rs index b644a3a5241..c949bba21bd 100644 --- a/packages/wasm-dpp2/src/platform_address/transitions/identity_credit_transfer_to_addresses_transition.rs +++ b/packages/wasm-dpp2/src/platform_address/transitions/identity_credit_transfer_to_addresses_transition.rs @@ -1,6 +1,6 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; -use crate::impl_wasm_conversions; +use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; use crate::platform_address::{ PlatformAddressOutputWasm, outputs_from_js_options, outputs_to_btree_map, @@ -262,7 +262,7 @@ impl IdentityCreditTransferToAddressesTransitionWasm { } } -impl_wasm_conversions!( +impl_wasm_conversions_serde!( IdentityCreditTransferToAddressesTransitionWasm, IdentityCreditTransferToAddresses, IdentityCreditTransferToAddressesObjectJs, diff --git a/packages/wasm-dpp2/src/platform_address/transitions/identity_top_up_from_addresses_transition.rs b/packages/wasm-dpp2/src/platform_address/transitions/identity_top_up_from_addresses_transition.rs index 3fb480cdc9e..4ca39908a26 100644 --- a/packages/wasm-dpp2/src/platform_address/transitions/identity_top_up_from_addresses_transition.rs +++ b/packages/wasm-dpp2/src/platform_address/transitions/identity_top_up_from_addresses_transition.rs @@ -1,6 +1,6 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; -use crate::impl_wasm_conversions; +use crate::impl_wasm_conversions_serde; use crate::impl_wasm_type_info; use crate::platform_address::{ PlatformAddressInputWasm, PlatformAddressOutputWasm, fee_strategy_from_js_options, @@ -242,7 +242,7 @@ impl IdentityTopUpFromAddressesTransitionWasm { } } -impl_wasm_conversions!( +impl_wasm_conversions_serde!( IdentityTopUpFromAddressesTransitionWasm, IdentityTopUpFromAddressesTransition, IdentityTopUpFromAddressesTransitionObjectJs,