diff --git a/packages/rs-drive-abci/src/abci/app/execution_result.rs b/packages/rs-drive-abci/src/abci/app/execution_result.rs index a451619ed4a..b715f3d96d0 100644 --- a/packages/rs-drive-abci/src/abci/app/execution_result.rs +++ b/packages/rs-drive-abci/src/abci/app/execution_result.rs @@ -29,14 +29,14 @@ impl TryIntoPlatformVersioned> for StateTransitionExecution gas_used: 0, ..Default::default() }), - StateTransitionExecutionResult::PaidConsensusError { error, actual_fees } => { - Some(ExecTxResult { - code: HandlerError::from(&error).code(), - info: error.response_info_for_version(platform_version)?, - gas_used: actual_fees.total_base_fee() as SignedCredits, - ..Default::default() - }) - } + StateTransitionExecutionResult::PaidConsensusError { + error, actual_fees, .. + } => Some(ExecTxResult { + code: HandlerError::from(&error).code(), + info: error.response_info_for_version(platform_version)?, + gas_used: actual_fees.total_base_fee() as SignedCredits, + ..Default::default() + }), StateTransitionExecutionResult::InternalError(message) => Some(ExecTxResult { code: HandlerError::Internal(message).code(), // TODO: That would be nice to provide more information about the error for debugging @@ -118,6 +118,7 @@ mod tests { let execution_result = StateTransitionExecutionResult::PaidConsensusError { error: consensus_error, actual_fees: fee_result.clone(), + address_balance_changes: BTreeMap::new(), }; let result: Option = execution_result 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 12e556bfd37..8d6fe37d5c0 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 @@ -1,5 +1,6 @@ use crate::error::execution::ExecutionError; use crate::error::Error; +use crate::execution::platform_events::state_transition_processing::record_added_balance_outputs::AddedBalanceOutputsOrigin; use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; use crate::platform_types::event_execution_result::EventExecutionResult; @@ -433,33 +434,15 @@ where previous_fee_versions, )?; - // Track address outputs if provided (e.g., for IdentityCreditTransferToAddresses) - if let Some(outputs) = added_to_balance_outputs { - if let Some(balance_updates) = address_balances_in_update { - for (address, credits) in outputs { - let new_op = CreditOperation::AddToCredits(credits); - balance_updates - .entry(address) - .and_modify(|existing| { - *existing = match existing { - // Set + Add = Set to combined value - CreditOperation::SetCredits(set_val) => { - CreditOperation::SetCredits( - set_val.saturating_add(credits), - ) - } - // Add + Add = saturating add - CreditOperation::AddToCredits(add_val) => { - CreditOperation::AddToCredits( - add_val.saturating_add(credits), - ) - } - }; - }) - .or_insert(new_op); - } - } - } + // Track address outputs if provided (e.g., for IdentityCreditTransferToAddresses). + // Transparent origin: this long-standing recording is preserved by every version + // (v0 and v1 both fold Transparent-origin outputs). + self.record_added_balance_outputs( + address_balances_in_update, + added_to_balance_outputs, + AddedBalanceOutputsOrigin::Transparent, + platform_version, + )?; Ok(result) } @@ -548,6 +531,7 @@ where ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool, + added_to_balance_outputs, chargeable_failure, } => { // An error-bearing `PaidFromShieldedPool` is legitimate ONLY for the @@ -587,6 +571,18 @@ where ) .map_err(Error::Drive)?; + // The ops just applied credited any transparent output address (an Unshield's + // recipient, including the chargeable-failure fallback address). Record that credit + // so incremental client sync sees it. ShieldedSpend origin: recorded only from v1 + // (protocol v13); v0 drops it so pre-v13 blocks byte-match. Reached only on the + // applied path (the early return above skips this). + self.record_added_balance_outputs( + address_balances_in_update, + added_to_balance_outputs, + AddedBalanceOutputsOrigin::ShieldedSpend, + platform_version, + )?; + // Split the carved fee like every other transition: the real storage // cost of the (permanent) shielded writes goes to the storage pool, so it // is amortised to the validators that store it over time and picks up the @@ -612,6 +608,7 @@ where } ExecutionEvent::PaidFromAssetLockToPool { fees_to_add_to_pool, + added_to_balance_outputs, operations, .. } => { @@ -633,6 +630,17 @@ where ) .map_err(Error::Drive)?; + // The ops just applied credited the shield's transparent surplus-output address + // (when set). Record that credit so incremental client sync sees it. ShieldedSpend + // origin: recorded only from v1 (protocol v13); v0 drops it so pre-v13 blocks + // byte-match. Reached only on the applied (no-errors) path. + self.record_added_balance_outputs( + address_balances_in_update, + added_to_balance_outputs, + AddedBalanceOutputsOrigin::ShieldedSpend, + platform_version, + )?; + // Route the real storage cost of the shielded writes to the storage pool // (amortised over time, epoch fee multiplier applied at payout); the // remainder is the processing fee paid to the proposer. Conservation: @@ -701,3 +709,147 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform_types::event_execution_result::EventExecutionResult; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + + /// Regression for the recent-address-balance-changes gap: a `PaidFromShieldedPool` carrying an + /// Unshield's output credit must fold that credit into the `address_balances_in_update` map (the + /// sole feed for `store_address_balances_for_block`), so incremental client sync can see the + /// unshield. Before the fix the arm applied the drive ops but never touched the map, leaving the + /// credit invisible to incremental sync. + #[test] + fn paid_from_shielded_pool_folds_output_credit_into_address_balances() { + // Pin v13: the executor's record_added_balance_outputs is v1 here, which folds + // shielded-spend-origin outputs (v0 would drop them). + let platform_version = PlatformVersion::get(13).expect("v13 must exist"); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let transaction = platform.drive.grove.start_transaction(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + + let output_address = PlatformAddress::P2pkh([0xBB; 20]); + let event = ExecutionEvent::PaidFromShieldedPool { + operations: vec![], + fees_to_add_to_pool: 0, + added_to_balance_outputs: Some(BTreeMap::from([(output_address, 2500)])), + chargeable_failure: false, + }; + + let mut address_balances: BTreeMap = BTreeMap::new(); + let result = platform + .platform + .execute_event_v0( + event, + vec![], + &BlockInfo::default(), + &transaction, + Some(&mut address_balances), + platform_version, + &fee_versions, + ) + .expect("execute_event_v0 should not error"); + + assert!( + matches!(result, EventExecutionResult::SuccessfulPaidExecution(..)), + "an ordinary unshield must execute successfully" + ); + assert_eq!( + address_balances.get(&output_address), + Some(&CreditOperation::AddToCredits(2500)), + "the unshield output credit must be recorded for incremental sync" + ); + } + + /// A `PaidFromShieldedPool` with no output (ShieldedTransfer / ShieldedWithdrawal, or a net-zero + /// unshield) must leave the address-balances map untouched. + #[test] + fn paid_from_shielded_pool_without_output_records_nothing() { + // Pin v13: the executor's record_added_balance_outputs is v1 here, which folds + // shielded-spend-origin outputs (v0 would drop them). + let platform_version = PlatformVersion::get(13).expect("v13 must exist"); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let transaction = platform.drive.grove.start_transaction(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + + let event = ExecutionEvent::PaidFromShieldedPool { + operations: vec![], + fees_to_add_to_pool: 0, + added_to_balance_outputs: None, + chargeable_failure: false, + }; + + let mut address_balances: BTreeMap = BTreeMap::new(); + platform + .platform + .execute_event_v0( + event, + vec![], + &BlockInfo::default(), + &transaction, + Some(&mut address_balances), + platform_version, + &fee_versions, + ) + .expect("execute_event_v0 should not error"); + + assert!( + address_balances.is_empty(), + "a shielded spend crediting no transparent address must record nothing" + ); + } + + /// The `PaidFromAssetLockToPool` counterpart: a `ShieldFromAssetLock` surplus credit must be + /// folded into the address-balances map on the applied (no-errors) path. + #[test] + fn paid_from_asset_lock_to_pool_folds_surplus_credit_into_address_balances() { + // Pin v13: the executor's record_added_balance_outputs is v1 here, which folds + // shielded-spend-origin outputs (v0 would drop them). + let platform_version = PlatformVersion::get(13).expect("v13 must exist"); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure(); + let transaction = platform.drive.grove.start_transaction(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + + let surplus_address = PlatformAddress::P2pkh([0x42; 20]); + let event = ExecutionEvent::PaidFromAssetLockToPool { + fees_to_add_to_pool: 1_000_000, + added_to_balance_outputs: Some(BTreeMap::from([(surplus_address, 2000)])), + operations: vec![], + execution_operations: vec![], + }; + + let mut address_balances: BTreeMap = BTreeMap::new(); + let result = platform + .platform + .execute_event_v0( + event, + vec![], + &BlockInfo::default(), + &transaction, + Some(&mut address_balances), + platform_version, + &fee_versions, + ) + .expect("execute_event_v0 should not error"); + + assert!( + matches!(result, EventExecutionResult::SuccessfulPaidExecution(..)), + "a sufficiently-funded shield-from-asset-lock must execute successfully" + ); + assert_eq!( + address_balances.get(&surplus_address), + Some(&CreditOperation::AddToCredits(2000)), + "the shield surplus credit must be recorded for incremental sync" + ); + } +} 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..387a762d383 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 @@ -2,5 +2,6 @@ mod cleanup_recent_block_storage_address_balances; mod decode_raw_state_transitions; mod execute_event; mod process_raw_state_transitions; +mod record_added_balance_outputs; mod store_address_balances_to_recent_block_storage; mod validate_fees_of_event; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index fa1b3012917..992d94bfaea 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -275,13 +275,20 @@ where // In this case the execution event will be to pay for the state transition processing // This ONLY pays for what is needed to prevent attacks on the system + // A paid-INVALID transition can still be APPLIED and credit a transparent address: the + // IdentityCreateFromShieldedPool duplicate-key fallback finalizes as a chargeable-failure + // UnshieldAction that credits the fallback address the net unshielded amount. Track those + // credits so they reach `address_balances_updated` for incremental sync. The executor + // only records into the map on the applied path, so ordinary (bump-only) paid-invalid + // transitions leave it empty. + let mut address_balances = BTreeMap::new(); let event_execution_result = self .execute_event( execution_event, errors, block_info, transaction, - None, // No address balance tracking for invalid state transitions + Some(&mut address_balances), platform_version, previous_fee_versions, ) @@ -313,6 +320,7 @@ where StateTransitionExecutionResult::PaidConsensusError { error: first_consensus_error, actual_fees, + address_balance_changes: address_balances, } } EventExecutionResult::SuccessfulFreeExecution => { @@ -429,9 +437,13 @@ where ); } + // Carry any address credits the applied ops recorded. For a chargeable-failure + // shielded spend that reaches this valid-branch UnsuccessfulPaidExecution arm, the + // executor already folded the credited address into `address_balances`. StateTransitionExecutionResult::PaidConsensusError { error: payment_consensus_error, actual_fees, + address_balance_changes: address_balances, } } EventExecutionResult::SuccessfulFreeExecution => { @@ -501,3 +513,117 @@ fn error_to_internal_error_execution_result( StateTransitionExecutionResult::InternalError(error_with_st.error.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::address_funds::PlatformAddress; + use dpp::balances::credits::CreditOperation; + use dpp::consensus::ConsensusError; + + // A `PaidFromShieldedPool` with `chargeable_failure = true` carrying an output credit is the shape + // the IdentityCreateFromShieldedPool duplicate-key fallback produces at this seam: an APPLIED, + // paid-invalid transition that still credits the fallback address (the net unshielded amount). + // The real fallback's event shape is covered by the `unshield_populates_net_output_credit` + // construction test in `execution_event`; here we drive the invalid branch of + // `process_validation_result_v0` and assert it carries that credit out. + fn fallback_event<'a>( + output: Option<(PlatformAddress, dpp::fee::Credits)>, + ) -> ExecutionEvent<'a> { + ExecutionEvent::PaidFromShieldedPool { + operations: vec![], + fees_to_add_to_pool: 0, + added_to_balance_outputs: output.map(|(addr, net)| BTreeMap::from([(addr, net)])), + chargeable_failure: true, + } + } + + /// Regression for the paid-INVALID address-credit gap: the applied chargeable-failure fallback + /// flows through the invalid branch of `process_validation_result_v0`, which used to pass `None` + /// for address tracking and silently drop the credit the block actually applied. Assert the + /// returned `PaidConsensusError` now carries the fallback-address credit so + /// `StateTransitionsProcessingResult::add` can merge it into `address_balances_updated`. + #[test] + fn invalid_branch_chargeable_failure_carries_address_credit() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let transaction = platform.drive.grove.start_transaction(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + + let fallback_address = PlatformAddress::P2pkh([0xCD; 20]); + let validation_result = ConsensusValidationResult::new_with_data_and_errors( + fallback_event(Some((fallback_address, 2500))), + vec![ConsensusError::DefaultError], + ); + + let result = platform + .platform + .process_validation_result_v0( + b"raw-st", + "Unshield", + validation_result, + &BlockInfo::default(), + &transaction, + platform_version, + &fee_versions, + ) + .expect("process_validation_result_v0 should not error"); + + match result { + StateTransitionExecutionResult::PaidConsensusError { + address_balance_changes, + .. + } => assert_eq!( + address_balance_changes.get(&fallback_address), + Some(&CreditOperation::AddToCredits(2500)), + "the applied fallback credit must be carried in PaidConsensusError" + ), + other => panic!("expected PaidConsensusError, got {other:?}"), + } + } + + /// An ordinary paid-invalid transition (a bump-only penalty crediting no address) must produce a + /// `PaidConsensusError` with an EMPTY balance-change map — the tracking added for the fallback + /// must not fabricate credits for the common case. + #[test] + fn invalid_branch_without_address_credit_carries_empty_map() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let transaction = platform.drive.grove.start_transaction(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + + let validation_result = ConsensusValidationResult::new_with_data_and_errors( + fallback_event(None), + vec![ConsensusError::DefaultError], + ); + + let result = platform + .platform + .process_validation_result_v0( + b"raw-st", + "Unshield", + validation_result, + &BlockInfo::default(), + &transaction, + platform_version, + &fee_versions, + ) + .expect("process_validation_result_v0 should not error"); + + match result { + StateTransitionExecutionResult::PaidConsensusError { + address_balance_changes, + .. + } => assert!( + address_balance_changes.is_empty(), + "a bump-only paid-invalid transition must carry no address credit" + ), + other => panic!("expected PaidConsensusError, got {other:?}"), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs new file mode 100644 index 00000000000..16a2b949287 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/mod.rs @@ -0,0 +1,190 @@ +mod v0; +mod v1; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::address_funds::PlatformAddress; +use dpp::balances::credits::CreditOperation; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use std::collections::BTreeMap; + +/// Which event family produced the `added_to_balance_outputs` being recorded. The recorded SET of +/// transparent-address credits changed at protocol v13, and this discriminator is what the versioned +/// `record_added_balance_outputs` method filters on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::execution) enum AddedBalanceOutputsOrigin { + /// Long-standing transparent credits (e.g. `IdentityCreditTransferToAddresses`). Recorded in + /// every version. + Transparent, + /// Shielded-spend transparent credits: the `Unshield` net output, the `ShieldFromAssetLock` + /// surplus, and the `IdentityCreateFromShieldedPool` duplicate-key fallback. Recorded only from + /// v1 (protocol v13); dropped by v0 so pre-v13 blocks byte-match. + ShieldedSpend, +} + +impl Platform +where + C: CoreRPCLike, +{ + /// Fold the transparent-address credits an applied event produced into the per-block + /// address-balance update map that feeds the recent-address-balance-changes tree. + /// + /// Versioned because the recorded SET changed at protocol v13: v0 records only + /// `Transparent`-origin credits (the historical behavior), v1 additionally records + /// `ShieldedSpend`-origin credits. Events always carry their outputs regardless of version — the + /// version decides only whether shielded-origin outputs are folded — so this method is the single + /// place the rolling-upgrade activation lives; construction and the downstream storage are + /// unconditional. + pub(in crate::execution) fn record_added_balance_outputs( + &self, + address_balances_in_update: Option<&mut BTreeMap>, + added_to_balance_outputs: Option>, + origin: AddedBalanceOutputsOrigin, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .state_transition_processing + .record_added_balance_outputs + { + 0 => self.record_added_balance_outputs_v0( + address_balances_in_update, + added_to_balance_outputs, + origin, + ), + 1 => self.record_added_balance_outputs_v1( + address_balances_in_update, + added_to_balance_outputs, + origin, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "record_added_balance_outputs".to_string(), + known_versions: vec![0, 1], + received: version, + })), + } + } +} + +/// Merge `added_to_balance_outputs` into `address_balances_in_update` (when both are present) with +/// the standard Set+Add merge semantics: Set + Add = Set(sum), Add + Add = Add(sum), both saturating. +/// Shared by v0 and v1 — the only difference between the two versions is the origin filter in the +/// caller, never the merge itself. +fn fold_added_balance_outputs( + address_balances_in_update: Option<&mut BTreeMap>, + added_to_balance_outputs: Option>, +) { + let (Some(balance_updates), Some(outputs)) = + (address_balances_in_update, added_to_balance_outputs) + else { + return; + }; + for (address, credits) in outputs { + balance_updates + .entry(address) + .and_modify(|existing| { + *existing = match existing { + // Set + Add = Set to combined value + CreditOperation::SetCredits(set_val) => { + CreditOperation::SetCredits(set_val.saturating_add(credits)) + } + // Add + Add = saturating add + CreditOperation::AddToCredits(add_val) => { + CreditOperation::AddToCredits(add_val.saturating_add(credits)) + } + }; + }) + .or_insert(CreditOperation::AddToCredits(credits)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::helpers::setup::TestPlatformBuilder; + + /// v0 (protocol < v13): `Transparent`-origin credits are folded (historical behavior); + /// `ShieldedSpend`-origin credits are dropped even though the event carried them. + #[test] + fn v0_folds_transparent_and_drops_shielded_spend() { + let platform = TestPlatformBuilder::new().build_with_mock_rpc(); + let v12 = PlatformVersion::get(12).expect("v12 must exist"); + + let transparent = PlatformAddress::P2pkh([0xA1; 20]); + let shielded = PlatformAddress::P2pkh([0xB2; 20]); + + // Transparent-origin: recorded by v0. + let mut map: BTreeMap = BTreeMap::new(); + platform + .record_added_balance_outputs( + Some(&mut map), + Some(BTreeMap::from([(transparent, 100)])), + AddedBalanceOutputsOrigin::Transparent, + v12, + ) + .expect("record must not error"); + assert_eq!( + map.get(&transparent), + Some(&CreditOperation::AddToCredits(100)), + "v0 must record transparent-origin credits" + ); + + // ShieldedSpend-origin: dropped by v0. + let mut map2: BTreeMap = BTreeMap::new(); + platform + .record_added_balance_outputs( + Some(&mut map2), + Some(BTreeMap::from([(shielded, 200)])), + AddedBalanceOutputsOrigin::ShieldedSpend, + v12, + ) + .expect("record must not error"); + assert!( + map2.is_empty(), + "v0 must DROP shielded-spend-origin credits (pre-v13 byte-match)" + ); + } + + /// v1 (protocol v13+): both origins are folded. + #[test] + fn v1_records_both_origins() { + let platform = TestPlatformBuilder::new().build_with_mock_rpc(); + let v13 = PlatformVersion::get(13).expect("v13 must exist"); + + let transparent = PlatformAddress::P2pkh([0xA1; 20]); + let shielded = PlatformAddress::P2pkh([0xB2; 20]); + + let mut map: BTreeMap = BTreeMap::new(); + platform + .record_added_balance_outputs( + Some(&mut map), + Some(BTreeMap::from([(transparent, 100)])), + AddedBalanceOutputsOrigin::Transparent, + v13, + ) + .expect("record must not error"); + platform + .record_added_balance_outputs( + Some(&mut map), + Some(BTreeMap::from([(shielded, 200)])), + AddedBalanceOutputsOrigin::ShieldedSpend, + v13, + ) + .expect("record must not error"); + + assert_eq!( + map.get(&transparent), + Some(&CreditOperation::AddToCredits(100)), + "v1 must record transparent-origin credits" + ); + assert_eq!( + map.get(&shielded), + Some(&CreditOperation::AddToCredits(200)), + "v1 must record shielded-spend-origin credits" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/v0/mod.rs new file mode 100644 index 00000000000..87683048feb --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/v0/mod.rs @@ -0,0 +1,33 @@ +use super::{fold_added_balance_outputs, AddedBalanceOutputsOrigin}; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::address_funds::PlatformAddress; +use dpp::balances::credits::CreditOperation; +use dpp::fee::Credits; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + /// v0: the HISTORICAL recorded set. Only `Transparent`-origin credits are folded (the `Paid` + /// arm's long-standing `IdentityCreditTransferToAddresses` recording). `ShieldedSpend`-origin + /// credits are DROPPED — the events carry them unconditionally now, but recording them would + /// change the committed state root, so pre-v13 blocks byte-match by discarding them here. + pub(super) fn record_added_balance_outputs_v0( + &self, + address_balances_in_update: Option<&mut BTreeMap>, + added_to_balance_outputs: Option>, + origin: AddedBalanceOutputsOrigin, + ) -> Result<(), Error> { + match origin { + AddedBalanceOutputsOrigin::Transparent => { + fold_added_balance_outputs(address_balances_in_update, added_to_balance_outputs); + } + // Dropped in v0: shielded-spend credits are not recorded before protocol v13. + AddedBalanceOutputsOrigin::ShieldedSpend => {} + } + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/v1/mod.rs new file mode 100644 index 00000000000..d99827ea798 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/record_added_balance_outputs/v1/mod.rs @@ -0,0 +1,28 @@ +use super::{fold_added_balance_outputs, AddedBalanceOutputsOrigin}; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::address_funds::PlatformAddress; +use dpp::balances::credits::CreditOperation; +use dpp::fee::Credits; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + /// v1 (protocol v13+): records BOTH origins. In addition to the historical `Transparent`-origin + /// credits, this folds `ShieldedSpend`-origin credits (the `Unshield` net output, the + /// `ShieldFromAssetLock` surplus, and the `IdentityCreateFromShieldedPool` fallback) so + /// incremental client sync observes shielded-spend payouts. The merge semantics are identical to + /// v0; only the origin filter differs (there is none here), so `origin` is unused. + pub(super) fn record_added_balance_outputs_v1( + &self, + address_balances_in_update: Option<&mut BTreeMap>, + added_to_balance_outputs: Option>, + _origin: AddedBalanceOutputsOrigin, + ) -> Result<(), Error> { + fold_added_balance_outputs(address_balances_in_update, added_to_balance_outputs); + Ok(()) + } +} 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 d0444676471..6d1e96f13d9 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 @@ -235,6 +235,7 @@ where fees_to_add_to_pool, operations, execution_operations, + .. } => { let mut estimated_fee_result = self .drive @@ -474,6 +475,7 @@ mod tests { ExecutionEvent::PaidFromShieldedPool { operations: vec![], fees_to_add_to_pool: 0, + added_to_balance_outputs: None, chargeable_failure: false, }, ExecutionEvent::Free { operations: vec![] }, @@ -530,6 +532,7 @@ mod tests { let fees_to_add_to_pool = 1_000_000u64; let event = ExecutionEvent::PaidFromAssetLockToPool { fees_to_add_to_pool, + added_to_balance_outputs: None, operations: vec![], execution_operations: vec![], }; 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 2605c2e8f40..4595c1c1ddf 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 @@ -92,6 +92,15 @@ pub(in crate::execution) enum ExecutionEvent<'a> { operations: Vec>, /// fees derived from value_balance to add to the fee pool fees_to_add_to_pool: Credits, + /// Transparent platform-address credits produced by this shielded spend, to be folded into + /// the recent-address-balance-changes tree (`store_address_balances_for_block`) so + /// incremental client sync sees them. `Some` only for an `UnshieldAction` whose NET amount + /// (`amount - fee`) is positive — mirroring the `AddBalanceToAddress` op the converter + /// emits — which includes the `IdentityCreateFromShieldedPool` duplicate-key fallback, + /// since that fallback surfaces as an `UnshieldAction` crediting the fallback address. + /// `None` for ShieldedTransfer and ShieldedWithdrawal: they credit no transparent + /// address, so there is nothing for incremental sync to observe. + added_to_balance_outputs: Option>, /// `true` ONLY for the `IdentityCreateFromShieldedPool` chargeable-failure fallback. It /// authorizes the executor to apply `operations` even when consensus errors are attached /// (the spend is finalized to the fallback address minus the penalty). For every ordinary @@ -124,6 +133,12 @@ pub(in crate::execution) enum ExecutionEvent<'a> { PaidFromAssetLockToPool { /// Fee (asset_lock_value - shield_amount) to add to the fee pool fees_to_add_to_pool: Credits, + /// Transparent platform-address credit produced by this shield, to be folded into the + /// recent-address-balance-changes tree (`store_address_balances_for_block`) so incremental + /// client sync sees it. `Some` only when `ShieldFromAssetLock` routes an asset-lock surplus + /// to a `surplus_output` address (mirroring the converter's `AddBalanceToAddress` op); `None` + /// when there is no surplus output (the surplus folds into the fee pools instead). + added_to_balance_outputs: Option>, /// the operations that should be performed operations: Vec>, /// the execution operations that we must also pay for @@ -544,6 +559,8 @@ impl ExecutionEvent<'_> { Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + // A shielded-to-shielded transfer credits no transparent address. + added_to_balance_outputs: None, chargeable_failure: false, }) } @@ -552,11 +569,31 @@ impl ExecutionEvent<'_> { // An ordinary Unshield is always `false`; only the IdentityCreateFromShieldedPool // duplicate-key fallback (which also surfaces as an UnshieldAction) sets it `true`. let chargeable_failure = unshield_action.chargeable_failure(); + // The converter credits the output address the NET amount (`amount - fee`) via an + // `AddBalanceToAddress` op, and only when that net is positive (see + // `unshield_transition.rs`). Thread the identical value here so the credit can be + // recorded in the recent-address-balance-changes tree for incremental sync. + // `checked_sub` returning `None` on underflow yields no output here — the converter + // call below then errors on the same underflow, so no event is constructed. + // + // This is populated UNCONDITIONALLY (the event is in-memory only). The rolling-upgrade + // versioning lives entirely in the executor's `record_added_balance_outputs` method: + // its v0 (protocol < v13) DROPS these shielded-spend-origin outputs, so pre-v13 blocks + // byte-match the earlier behavior; its v1 (v13+) records them. Keeping construction + // version-independent means there is exactly one versioned site. + let added_to_balance_outputs = unshield_action + .amount() + .checked_sub(fee_amount) + .filter(|net_recipient_amount| *net_recipient_amount > 0) + .map(|net_recipient_amount| { + BTreeMap::from([(*unshield_action.output_address(), net_recipient_amount)]) + }); let operations = action.into_high_level_drive_operations(epoch, platform_version)?; Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + added_to_balance_outputs, chargeable_failure, }) } @@ -578,10 +615,32 @@ impl ExecutionEvent<'_> { "shield amount + surplus exceeds asset lock value to be consumed in ShieldFromAssetLock fee computation", ), ))?; + // The converter routes `surplus_amount` to the `surplus_output` platform address via + // an `AddBalanceToAddress` op, but only when the output is set AND the surplus is + // positive (see `shield_from_asset_lock_transition.rs`). Thread the identical credit + // here so it can be recorded in the recent-address-balance-changes tree for + // incremental sync. When there is no surplus output the surplus folds into the fee + // pools instead, crediting no address, so this stays `None`. + // + // Populated UNCONDITIONALLY, like the `UnshieldAction` arm above: the rolling-upgrade + // versioning lives in the executor's `record_added_balance_outputs` method (v0 drops + // these shielded-spend-origin outputs, v1 records them), so construction stays + // version-independent and there is exactly one versioned site. + let added_to_balance_outputs = match shield_from_asset_lock_action.surplus_output() + { + Some(surplus_address) if shield_from_asset_lock_action.surplus_amount() > 0 => { + Some(BTreeMap::from([( + *surplus_address, + shield_from_asset_lock_action.surplus_amount(), + )])) + } + _ => None, + }; let operations = action.into_high_level_drive_operations(epoch, platform_version)?; Ok(ExecutionEvent::PaidFromAssetLockToPool { fees_to_add_to_pool: fee_amount, + added_to_balance_outputs, operations, execution_operations: execution_context.operations_consume(), }) @@ -593,6 +652,9 @@ impl ExecutionEvent<'_> { Ok(ExecutionEvent::PaidFromShieldedPool { operations, fees_to_add_to_pool: fee_amount, + // A shielded withdrawal leaves Platform for a Core-chain output; it credits no + // transparent platform address, so there is nothing to record here. + added_to_balance_outputs: None, chargeable_failure: false, }) } @@ -650,3 +712,322 @@ impl ExecutionEvent<'_> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::version::DefaultForPlatformVersion; + use drive::state_transition_action::shielded::shield_from_asset_lock::v0::ShieldFromAssetLockTransitionActionV0; + use drive::state_transition_action::shielded::shield_from_asset_lock::ShieldFromAssetLockTransitionAction; + use drive::state_transition_action::shielded::unshield::v0::UnshieldTransitionActionV0; + use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; + use drive::state_transition_action::shielded::ShieldedActionNote; + + fn note() -> ShieldedActionNote { + ShieldedActionNote { + nullifier: [0x11; 32], + cmx: [0x22; 32], + cv_net: [0x33; 32], + encrypted_note: vec![1, 2, 3], + } + } + + fn ctx() -> StateTransitionExecutionContext { + StateTransitionExecutionContext::default_for_platform_version(PlatformVersion::latest()) + .expect("execution context") + } + + /// An `Unshield` crediting its output address a positive NET (`amount - fee`) must carry that + /// credit in `PaidFromShieldedPool::added_to_balance_outputs`, so the executor can record it in + /// the recent-address-balance-changes tree that incremental client sync reads. + #[test] + fn unshield_populates_net_output_credit() { + let output_address = PlatformAddress::P2pkh([0xBB; 20]); + let action = StateTransitionAction::UnshieldAction(UnshieldTransitionAction::V0( + UnshieldTransitionActionV0 { + output_address, + amount: 3000, + notes: vec![note()], + anchor: [0xAA; 32], + fee_amount: 500, + current_total_balance: 10_000, + chargeable_failure: false, + }, + )); + + let event = ExecutionEvent::create_from_state_transition_action( + action, + None, + &Epoch::new(0).unwrap(), + ctx(), + PlatformVersion::latest(), + ) + .expect("create event"); + + match event { + ExecutionEvent::PaidFromShieldedPool { + added_to_balance_outputs, + .. + } => { + let outputs = + added_to_balance_outputs.expect("net > 0 must carry an output credit"); + assert_eq!(outputs.len(), 1); + // 3000 amount - 500 fee = 2500 net to the output address. + assert_eq!(outputs.get(&output_address).copied(), Some(2500)); + } + _ => panic!("expected PaidFromShieldedPool"), + } + } + + /// A net-zero `Unshield` (the whole unshielding amount consumed by the fee) credits no address — + /// the converter emits no `AddBalanceToAddress`, so the event must carry no output either. + #[test] + fn unshield_net_zero_records_no_output_credit() { + let action = StateTransitionAction::UnshieldAction(UnshieldTransitionAction::V0( + UnshieldTransitionActionV0 { + output_address: PlatformAddress::P2pkh([0xBB; 20]), + amount: 500, + notes: vec![note()], + anchor: [0xAA; 32], + fee_amount: 500, // net = 0 + current_total_balance: 10_000, + chargeable_failure: false, + }, + )); + + let event = ExecutionEvent::create_from_state_transition_action( + action, + None, + &Epoch::new(0).unwrap(), + ctx(), + PlatformVersion::latest(), + ) + .expect("create event"); + + match event { + ExecutionEvent::PaidFromShieldedPool { + added_to_balance_outputs, + .. + } => assert!( + added_to_balance_outputs.is_none(), + "net-zero unshield must credit no address" + ), + _ => panic!("expected PaidFromShieldedPool"), + } + } + + /// The IdentityCreateFromShieldedPool duplicate-key fallback surfaces as a `chargeable_failure` + /// `UnshieldAction` that still credits the fallback address the net amount. The event must carry + /// that credit AND set `chargeable_failure`, so the executor records it on the applied path even + /// though the transition is paid-invalid. (The `chargeable_failure` flag is independent of the + /// output-credit computation — this pins the exact shape the paid-invalid seam relies on.) + #[test] + fn unshield_chargeable_failure_still_populates_net_output_credit() { + let output_address = PlatformAddress::P2pkh([0xCD; 20]); + let action = StateTransitionAction::UnshieldAction(UnshieldTransitionAction::V0( + UnshieldTransitionActionV0 { + output_address, + amount: 3000, + notes: vec![note()], + anchor: [0xAA; 32], + fee_amount: 500, + current_total_balance: 10_000, + chargeable_failure: true, + }, + )); + + let event = ExecutionEvent::create_from_state_transition_action( + action, + None, + &Epoch::new(0).unwrap(), + ctx(), + PlatformVersion::latest(), + ) + .expect("create event"); + + match event { + ExecutionEvent::PaidFromShieldedPool { + added_to_balance_outputs, + chargeable_failure, + .. + } => { + assert!(chargeable_failure, "the fallback must be chargeable"); + let outputs = added_to_balance_outputs + .expect("fallback must carry the fallback-address credit"); + assert_eq!(outputs.get(&output_address).copied(), Some(2500)); + } + _ => panic!("expected PaidFromShieldedPool"), + } + } + + /// A `ShieldFromAssetLock` routing a surplus to a `surplus_output` address must carry that credit + /// in `PaidFromAssetLockToPool::added_to_balance_outputs`. + #[test] + fn shield_from_asset_lock_populates_surplus_credit() { + let surplus_address = PlatformAddress::P2pkh([0x42; 20]); + let action = StateTransitionAction::ShieldFromAssetLockAction( + ShieldFromAssetLockTransitionAction::V0(ShieldFromAssetLockTransitionActionV0 { + asset_lock_outpoint: [0xDD; 36], + asset_lock_value_to_be_consumed: 10_000, + signable_bytes_hasher: [0xEE; 32], + shield_amount: 5_000, + notes: vec![note()], + current_total_balance: 10_000, + surplus_output: Some(surplus_address), + surplus_amount: 2_000, + }), + ); + + let event = ExecutionEvent::create_from_state_transition_action( + action, + None, + &Epoch::new(0).unwrap(), + ctx(), + PlatformVersion::latest(), + ) + .expect("create event"); + + match event { + ExecutionEvent::PaidFromAssetLockToPool { + added_to_balance_outputs, + fees_to_add_to_pool, + .. + } => { + let outputs = + added_to_balance_outputs.expect("surplus must carry an output credit"); + assert_eq!(outputs.get(&surplus_address).copied(), Some(2_000)); + // fee = consumed - shield - surplus = 10000 - 5000 - 2000. + assert_eq!(fees_to_add_to_pool, 3_000); + } + _ => panic!("expected PaidFromAssetLockToPool"), + } + } + + /// A `ShieldFromAssetLock` with no `surplus_output` credits no address (the surplus folds into + /// the fee pools), so the event must carry no output. + #[test] + fn shield_from_asset_lock_without_surplus_output_records_nothing() { + let action = StateTransitionAction::ShieldFromAssetLockAction( + ShieldFromAssetLockTransitionAction::V0(ShieldFromAssetLockTransitionActionV0 { + asset_lock_outpoint: [0xDD; 36], + asset_lock_value_to_be_consumed: 10_000, + signable_bytes_hasher: [0xEE; 32], + shield_amount: 5_000, + notes: vec![note()], + current_total_balance: 10_000, + surplus_output: None, + surplus_amount: 0, + }), + ); + + let event = ExecutionEvent::create_from_state_transition_action( + action, + None, + &Epoch::new(0).unwrap(), + ctx(), + PlatformVersion::latest(), + ) + .expect("create event"); + + match event { + ExecutionEvent::PaidFromAssetLockToPool { + added_to_balance_outputs, + .. + } => assert!(added_to_balance_outputs.is_none()), + _ => panic!("expected PaidFromAssetLockToPool"), + } + } + + /// Construction is now VERSION-INDEPENDENT: an `Unshield` with a positive net always carries the + /// output credit in the event, at BOTH v12 and v13. The rolling-upgrade gate moved to the + /// executor's `record_added_balance_outputs` (v0 drops shielded-spend outputs, v1 records them), + /// so the event carries the credit either way — only whether it is recorded differs. + #[test] + fn unshield_credit_populated_at_construction_regardless_of_version() { + let output_address = PlatformAddress::P2pkh([0xBB; 20]); + let make_action = || { + StateTransitionAction::UnshieldAction(UnshieldTransitionAction::V0( + UnshieldTransitionActionV0 { + output_address, + amount: 3000, + notes: vec![note()], + anchor: [0xAA; 32], + fee_amount: 500, + current_total_balance: 10_000, + chargeable_failure: false, + }, + )) + }; + + for platform_version in [ + PlatformVersion::get(12).expect("v12 must exist"), + PlatformVersion::get(13).expect("v13 must exist"), + ] { + let event = ExecutionEvent::create_from_state_transition_action( + make_action(), + None, + &Epoch::new(0).unwrap(), + ctx(), + platform_version, + ) + .expect("create event"); + match event { + ExecutionEvent::PaidFromShieldedPool { + added_to_balance_outputs, + .. + } => { + let outputs = added_to_balance_outputs + .expect("construction always carries the unshield credit (net > 0)"); + assert_eq!(outputs.get(&output_address).copied(), Some(2500)); + } + _ => panic!("expected PaidFromShieldedPool"), + } + } + } + + /// Construction is version-independent for the `ShieldFromAssetLock` surplus too: the surplus + /// credit is carried in the event at BOTH v12 and v13 (the recording gate lives in the executor). + #[test] + fn shield_surplus_populated_at_construction_regardless_of_version() { + let surplus_address = PlatformAddress::P2pkh([0x42; 20]); + let make_action = || { + StateTransitionAction::ShieldFromAssetLockAction( + ShieldFromAssetLockTransitionAction::V0(ShieldFromAssetLockTransitionActionV0 { + asset_lock_outpoint: [0xDD; 36], + asset_lock_value_to_be_consumed: 10_000, + signable_bytes_hasher: [0xEE; 32], + shield_amount: 5_000, + notes: vec![note()], + current_total_balance: 10_000, + surplus_output: Some(surplus_address), + surplus_amount: 2_000, + }), + ) + }; + + for platform_version in [ + PlatformVersion::get(12).expect("v12 must exist"), + PlatformVersion::get(13).expect("v13 must exist"), + ] { + let event = ExecutionEvent::create_from_state_transition_action( + make_action(), + None, + &Epoch::new(0).unwrap(), + ctx(), + platform_version, + ) + .expect("create event"); + match event { + ExecutionEvent::PaidFromAssetLockToPool { + added_to_balance_outputs, + .. + } => { + let outputs = added_to_balance_outputs + .expect("construction always carries the shield surplus credit"); + assert_eq!(outputs.get(&surplus_address).copied(), Some(2_000)); + } + _ => panic!("expected PaidFromAssetLockToPool"), + } + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 65d7bf16d4b..96e66aede9e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -492,7 +492,8 @@ mod creation_tests { processing_fee: 526140, fee_refunds: FeeRefunds::default(), removed_bytes_from_system: 0 - } + }, + address_balance_changes: std::collections::BTreeMap::new() } ); diff --git a/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs b/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs index c785fad0d93..4398ff07d31 100644 --- a/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/state_transitions_processing_result/mod.rs @@ -25,6 +25,14 @@ pub enum StateTransitionExecutionResult { error: ConsensusError, /// Actual fees charged actual_fees: FeeResult, + /// Address balance changes applied by this paid-INVALID transition. Non-empty only for an + /// APPLIED chargeable-failure transition that still credits a transparent address — e.g. the + /// `IdentityCreateFromShieldedPool` duplicate-key fallback, which finalizes as an + /// `UnshieldAction` (paid-invalid) yet credits the fallback address the net unshielded + /// amount. These must reach `address_balances_updated` so incremental sync sees the GroveDB + /// credit the block actually applied. Empty for ordinary paid-invalid transitions (bump-only + /// penalties that credit no address). + address_balance_changes: BTreeMap, }, /// State Transition is invalid, and is not paid for because we either : /// * don't have a proved identity associated with it so we can't deduct balance. @@ -107,9 +115,19 @@ impl StateTransitionsProcessingResult { StateTransitionExecutionResult::InternalError(_) => { self.failed_count += 1; } - StateTransitionExecutionResult::PaidConsensusError { actual_fees, .. } => { + StateTransitionExecutionResult::PaidConsensusError { + actual_fees, + address_balance_changes, + .. + } => { self.invalid_paid_count += 1; self.fees.checked_add_assign(actual_fees.clone())?; + + // An applied chargeable-failure transition (e.g. the identity-create-from-shielded- + // pool fallback) can credit a transparent address even though it is paid-invalid; + // merge those credits so incremental sync sees them. Ordinary paid-invalid + // transitions carry an empty map, so this is a no-op for them. + self.add_address_balances_in_update(address_balance_changes.clone()); } StateTransitionExecutionResult::UnpaidConsensusError(_) => { self.invalid_unpaid_count += 1; @@ -171,3 +189,54 @@ impl StateTransitionsProcessingResult { &self.execution_results } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A `PaidConsensusError` carrying address credits (the applied chargeable-failure fallback, + /// e.g. IdentityCreateFromShieldedPool's duplicate-key path) must merge those credits into + /// `address_balances_updated`, exactly like `SuccessfulExecution` — otherwise a credit the block + /// applied never reaches the recent-address-balance-changes tree that incremental sync reads. + #[test] + fn add_merges_paid_consensus_error_address_changes() { + let mut result = StateTransitionsProcessingResult::default(); + let address = PlatformAddress::P2pkh([0xCD; 20]); + + result + .add(StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::DefaultError, + actual_fees: FeeResult::default(), + address_balance_changes: BTreeMap::from([( + address, + CreditOperation::AddToCredits(2500), + )]), + }) + .expect("add should succeed"); + + assert_eq!(result.invalid_paid_count(), 1); + assert_eq!( + result.address_balances_updated.get(&address), + Some(&CreditOperation::AddToCredits(2500)), + "PaidConsensusError credits must reach address_balances_updated" + ); + } + + /// An ordinary `PaidConsensusError` (bump-only penalty, no credit) must leave + /// `address_balances_updated` empty. + #[test] + fn add_paid_consensus_error_without_changes_leaves_balances_empty() { + let mut result = StateTransitionsProcessingResult::default(); + + result + .add(StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::DefaultError, + actual_fees: FeeResult::default(), + address_balance_changes: BTreeMap::new(), + }) + .expect("add should succeed"); + + assert_eq!(result.invalid_paid_count(), 1); + assert!(result.address_balances_updated.is_empty()); + } +} 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 6255f4610f2..8d5fc77db9a 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 @@ -8,6 +8,7 @@ pub mod v5; pub mod v6; pub mod v7; pub mod v8; +pub mod v9; #[derive(Clone, Debug, Default)] pub struct DriveAbciMethodVersions { @@ -180,4 +181,12 @@ pub struct DriveAbciStateTransitionProcessingMethodVersions { pub validate_fees_of_event: FeatureVersion, pub store_address_balances_to_recent_block_storage: OptionalFeatureVersion, pub cleanup_recent_block_storage_address_balances: OptionalFeatureVersion, + /// Version of `record_added_balance_outputs`, the method that folds an applied event's + /// transparent-address credits into the per-block address-balance updates. v0 records only the + /// long-standing transparent credits (e.g. `IdentityCreditTransferToAddresses`); v1 additionally + /// records shielded-spend transparent credits (Unshield net output, ShieldFromAssetLock surplus, + /// IdentityCreateFromShieldedPool fallback). The storage method + /// (`store_address_balances_to_recent_block_storage`) is unchanged across the bump — only the + /// recorded set differs. + pub record_added_balance_outputs: FeatureVersion, } 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 7f8c9d6716d..329bf57350a 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 @@ -107,6 +107,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V1: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 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 f888a082728..dfad62fd1fc 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 @@ -108,6 +108,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V2: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 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 e62d6fdf292..9d4ec4b41b8 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 @@ -107,6 +107,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V3: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 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 97a56422e76..7f0336ed95a 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 @@ -107,6 +107,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V4: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 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 321553aa6c8..951b8353ce5 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 @@ -111,6 +111,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V5: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 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 e2920929a13..c7cf48156b2 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 @@ -109,6 +109,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V6: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 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 080982ba6e0..3e0da7e883e 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 @@ -116,6 +116,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V7: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: Some(0), // changed cleanup_recent_block_storage_address_balances: Some(0), // cleanup enabled when store is enabled + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs index a33f3f2bedb..8dd2f356c75 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs @@ -118,6 +118,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V8: DriveAbciMethodVersions = DriveAbciMeth validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: Some(0), cleanup_recent_block_storage_address_balances: Some(0), + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs new file mode 100644 index 00000000000..943575eaadc --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs @@ -0,0 +1,148 @@ +use crate::version::drive_abci_versions::drive_abci_method_versions::{ + DriveAbciBlockEndMethodVersions, DriveAbciBlockFeeProcessingMethodVersions, + DriveAbciBlockStartMethodVersions, DriveAbciCoreBasedUpdatesMethodVersions, + DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciCoreInstantSendLockMethodVersions, + DriveAbciEngineMethodVersions, DriveAbciEpochMethodVersions, + DriveAbciFeePoolInwardsDistributionMethodVersions, + DriveAbciFeePoolOutwardsDistributionMethodVersions, + DriveAbciIdentityCreditWithdrawalMethodVersions, DriveAbciInitializationMethodVersions, + DriveAbciMasternodeIdentitiesUpdatesMethodVersions, DriveAbciMethodVersions, + DriveAbciPlatformStateStorageMethodVersions, DriveAbciProtocolUpgradeMethodVersions, + DriveAbciStateTransitionProcessingMethodVersions, DriveAbciTokensProcessingMethodVersions, + DriveAbciVotingMethodVersions, +}; + +// Introduced in Protocol version 13. +// +// Identical to DRIVE_ABCI_METHOD_VERSIONS_V8 (the protocol-v12 method set) +// except `record_added_balance_outputs` is bumped from 0 to 1. v1 additionally +// records shielded-spend transparent credits (Unshield net output, +// ShieldFromAssetLock surplus, and the IdentityCreateFromShieldedPool +// duplicate-key fallback) into the recent per-block address-balance set; v0 +// dropped them. The storage method `store_address_balances_to_recent_block_storage` +// is deliberately UNCHANGED (stays Some(0)) — its _v0 implementation did not +// change; only which credits feed its map did. The expanded recorded set changes +// the committed state root, so v1 MUST stay inactive on v12 (see +// DRIVE_ABCI_METHOD_VERSIONS_V8, which keeps `record_added_balance_outputs: 0`). +pub const DRIVE_ABCI_METHOD_VERSIONS_V9: DriveAbciMethodVersions = DriveAbciMethodVersions { + engine: DriveAbciEngineMethodVersions { + init_chain: 0, + check_tx: 0, + run_block_proposal: 0, + finalize_block_proposal: 0, + consensus_params_update: 1, + }, + initialization: DriveAbciInitializationMethodVersions { + initial_core_height_and_time: 0, + create_genesis_state: 1, + }, + core_based_updates: DriveAbciCoreBasedUpdatesMethodVersions { + update_core_info: 0, + update_masternode_list: 0, + update_quorum_info: 0, + masternode_updates: DriveAbciMasternodeIdentitiesUpdatesMethodVersions { + get_voter_identity_key: 0, + get_operator_identity_keys: 0, + get_owner_identity_withdrawal_key: 0, + get_owner_identity_owner_key: 0, + get_voter_identifier_from_masternode_list_item: 0, + get_operator_identifier_from_masternode_list_item: 0, + create_operator_identity: 0, + create_owner_identity: 1, + create_voter_identity: 0, + disable_identity_keys: 0, + update_masternode_identities: 0, + update_operator_identity: 0, + update_owner_withdrawal_address: 1, + update_voter_identity: 0, + }, + }, + protocol_upgrade: DriveAbciProtocolUpgradeMethodVersions { + check_for_desired_protocol_upgrade: 1, + upgrade_protocol_version_on_epoch_change: 0, + perform_events_on_first_block_of_protocol_change: Some(0), + protocol_version_upgrade_percentage_needed: 67, + }, + block_fee_processing: DriveAbciBlockFeeProcessingMethodVersions { + add_process_epoch_change_operations: 0, + process_block_fees_and_validate_sum_trees: 1, + }, + tokens_processing: DriveAbciTokensProcessingMethodVersions { + validate_token_aggregated_balance: 0, + }, + core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { + choose_quorum: 0, + verify_chain_lock: 0, + verify_chain_lock_locally: 0, + verify_chain_lock_through_core: 0, + make_sure_core_is_synced_to_chain_lock: 0, + recent_block_count_amount: 2, + }, + core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { + verify_recent_signature_locally: 0, + }, + fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { + add_distribute_block_fees_into_pools_operations: 0, + add_distribute_storage_fee_to_epochs_operations: 0, + }, + fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { + add_distribute_fees_from_oldest_unpaid_epoch_pool_to_proposers_operations: 1, + add_epoch_pool_to_proposers_payout_operations: 0, + find_oldest_epoch_needing_payment: 0, + fetch_reward_shares_list_for_masternode: 0, + }, + withdrawals: DriveAbciIdentityCreditWithdrawalMethodVersions { + build_untied_withdrawal_transactions_from_documents: 0, + dequeue_and_build_unsigned_withdrawal_transactions: 0, + fetch_transactions_block_inclusion_status: 0, + pool_withdrawals_into_transactions_queue: 1, + update_broadcasted_withdrawal_statuses: 0, + rebroadcast_expired_withdrawal_documents: 1, + append_signatures_and_broadcast_withdrawal_transactions: 0, + cleanup_expired_locks_of_withdrawal_amounts: 0, + }, + voting: DriveAbciVotingMethodVersions { + keep_record_of_finished_contested_resource_vote_poll: 0, + clean_up_after_vote_poll_end: 0, + clean_up_after_contested_resources_vote_poll_end: 1, + check_for_ended_vote_polls: 0, + tally_votes_for_contested_document_resource_vote_poll: 0, + award_document_to_winner: 0, + delay_vote_poll: 0, + run_dao_platform_events: 0, + remove_votes_for_removed_masternodes: 0, + }, + state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { + execute_event: 0, + process_raw_state_transitions: 0, + decode_raw_state_transitions: 0, + validate_fees_of_event: 0, + store_address_balances_to_recent_block_storage: Some(0), + cleanup_recent_block_storage_address_balances: Some(0), + // changed: v1 records shielded-spend transparent credits (Unshield net output, + // ShieldFromAssetLock surplus, identity-create fallback) in addition to the long-standing + // transparent credits. The storage method above is unchanged — only which credits feed its + // map did. + record_added_balance_outputs: 1, + }, + epoch: DriveAbciEpochMethodVersions { + gather_epoch_info: 0, + get_genesis_time: 0, + }, + block_start: DriveAbciBlockStartMethodVersions { + clear_drive_block_cache: 0, + }, + block_end: DriveAbciBlockEndMethodVersions { + update_state_cache: 0, + update_drive_cache: 0, + validator_set_update: 2, + 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, + store_platform_state: 0, + }, +}; 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 ed69f63081a..636952964be 100644 --- a/packages/rs-platform-version/src/version/mocks/v3_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v3_test.rs @@ -142,6 +142,7 @@ pub const TEST_PLATFORM_V3: PlatformVersion = PlatformVersion { validate_fees_of_event: 0, store_address_balances_to_recent_block_storage: None, cleanup_recent_block_storage_address_balances: None, + record_added_balance_outputs: 0, }, epoch: DriveAbciEpochMethodVersions { gather_epoch_info: 0, diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index b4416d08f72..a034667fd4a 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -228,4 +228,44 @@ mod shielded_pool_gating_tests { assert_eq!(block_end.record_shielded_pool_anchor, Some(0)); assert_eq!(block_end.prune_shielded_pool_anchors, Some(0)); } + + // Recording shielded-spend transparent credits (Unshield net output, ShieldFromAssetLock + // surplus, identity-create fallback) into the recent per-block address-balance set changes the + // committed state root, so it is gated to v13 via the `record_added_balance_outputs` method + // version: v0 (protocol < v13) records only long-standing transparent credits, v1 (v13+) also + // records shielded-spend credits. Crucially the storage method + // `store_address_balances_to_recent_block_storage` is NOT bumped — its _v0 implementation did + // not change — so it stays Some(0) at BOTH versions; only the recorded set differs. + #[test] + fn shielded_transparent_credit_recording_gated_to_v13() { + let v12 = PlatformVersion::get(12).expect("protocol version 12 must exist"); + let v13 = PlatformVersion::get(13).expect("protocol version 13 must exist"); + let stp12 = &v12.drive_abci.methods.state_transition_processing; + let stp13 = &v13.drive_abci.methods.state_transition_processing; + + // The recording method version: v0 before v13 (drops shielded-spend), v1 from v13 (records). + assert_eq!( + stp12.record_added_balance_outputs, 0, + "v12 must use record_added_balance_outputs v0 (drops shielded-spend credits)" + ); + assert_eq!( + stp13.record_added_balance_outputs, 1, + "v13 must use record_added_balance_outputs v1 (records shielded-spend credits)" + ); + + // The storage method is UNCHANGED across the gate — we must not bump a method whose _v0 + // implementation did not change. It stays Some(0) at both v12 and v13. + assert_eq!( + stp12.store_address_balances_to_recent_block_storage, + Some(0), + "v12 store method is Some(0)" + ); + assert_eq!( + stp13.store_address_balances_to_recent_block_storage, + Some(0), + "v13 must NOT bump the unchanged store method" + ); + // Cleanup mechanics likewise unchanged. + assert_eq!(stp13.cleanup_recent_block_storage_address_balances, Some(0)); + } } diff --git a/packages/rs-platform-version/src/version/v13.rs b/packages/rs-platform-version/src/version/v13.rs index eb5626e3b4b..71c450716e1 100644 --- a/packages/rs-platform-version/src/version/v13.rs +++ b/packages/rs-platform-version/src/version/v13.rs @@ -15,7 +15,7 @@ use crate::version::dpp_versions::dpp_validation_versions::v3::DPP_VALIDATION_VE use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; -use crate::version::drive_abci_versions::drive_abci_method_versions::v8::DRIVE_ABCI_METHOD_VERSIONS_V8; +use crate::version::drive_abci_versions::drive_abci_method_versions::v9::DRIVE_ABCI_METHOD_VERSIONS_V9; use crate::version::drive_abci_versions::drive_abci_query_versions::v1::DRIVE_ABCI_QUERY_VERSIONS_V1; use crate::version::drive_abci_versions::drive_abci_structure_versions::v1::DRIVE_ABCI_STRUCTURE_VERSIONS_V1; use crate::version::drive_abci_versions::drive_abci_validation_versions::v8::DRIVE_ABCI_VALIDATION_VERSIONS_V8; @@ -30,19 +30,23 @@ use crate::version::ProtocolVersion; pub const PROTOCOL_VERSION_13: ProtocolVersion = 13; -/// Introduced as the activation gate for recording shielded-spend transparent -/// credits (Unshield / shield surplus / identity-create fallback) into the -/// recent-address-balance-changes tree. Functionally identical to v12 at -/// introduction — the same component version structs, no behavior change. The -/// consensus change that consumes this gate (a bumped drive-abci methods -/// struct) lands in a follow-up; keeping v13 == v12 here lets mixed-version -/// validators agree until that change activates. +/// Introduced to record shielded-spend transparent credits (Unshield net +/// output, ShieldFromAssetLock surplus, identity-create fallback) into the +/// recent per-block address-balance set that incremental client sync reads. The +/// only delta from v12 is the drive-abci methods struct +/// (DRIVE_ABCI_METHOD_VERSIONS_V9), which bumps `record_added_balance_outputs` +/// from 0 to 1: v1 folds shielded-spend-origin credits (which v0 dropped) into +/// the per-block address-balance map. The storage method +/// (`store_address_balances_to_recent_block_storage`) is unchanged — only the +/// recorded set differs. Because that set changes the committed state root, the +/// bump lives in a dedicated v13 method set and only activates once the network +/// votes in v13. pub const PLATFORM_V13: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_13, drive: DRIVE_VERSION_V7, drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, - methods: DRIVE_ABCI_METHOD_VERSIONS_V8, + methods: DRIVE_ABCI_METHOD_VERSIONS_V9, // changed: records shielded-spend transparent credits (Unshield net output, ShieldFromAssetLock surplus, identity-create fallback) into the recent per-block address-balance set validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V8, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2, query: DRIVE_ABCI_QUERY_VERSIONS_V1,