diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index d9c2c4f3be4..4ffdf327d47 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -451,6 +451,24 @@ storage and processing, plus the `shielded_verification_fee` folded into process (into the pool), `surplus_amount` (to `surplus_output`, or `0`), and `fee_amount` (to the fee pools); see [Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity). +From protocol version 14, an authenticated `Shield` whose Orchard proof fails +moves no credits into the pool. Its input nonces are consumed and its address +inputs pay the metered failure fee plus `shielded_proof_verification_failure`. +The penalty is capped at the funds reachable by the signed fee strategy after +reserving the estimated base fee. It is charged once as a fixed processing fee, +without the user's fee increase. If those funds cannot cover even the base fee, +the refusal is unpaid and consumes no nonce. Duplicate-nullifier refusals remain +unpaid. CheckTx rejects bad proofs before mempool admission; a directly proposed +funded bad proof is a paid failure that validators can accept. + +This policy requires Shield wire format 1, whose format tag is covered by the +address witnesses. Format 0 is accepted only at protocol versions 12 and 13; +after activation it is refused before authentication or proof verification, +without charging fees or consuming input nonces. A pending legacy Shield must +be rebuilt and re-signed against the active version. Changing its format tag +alone invalidates its address witnesses. Historical blocks retain their original +wire format and unpaid proof-failure behavior. + ## Cryptographic Binding The fee is not just a field that the platform trusts. It is cryptographically bound diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index f2395089285..5acfe12bcd3 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -51,6 +51,15 @@ This is used when a field can accept a *range* of versions -- for example, a data contract serialization format where the system can read versions 0 through 2 but writes version 2 by default. +A state transition can also retire an older signed format. Shield uses format 0 +at protocol versions 12 and 13, and format 1 from version 14: the serialization +bounds select the client format before signing, while +`StateTransition::active_version_range` enforces acceptance at untrusted +decoding. The format tag is part of the address signing bytes. Legacy Shield +bytes therefore cannot authorize the bound proof domain and paid proof-failure +policy after activation; pending transactions must be rebuilt and re-signed. +The historical format and validation generations remain available for replay. + ## Version Structs: The Middle of the Tree Between the top-level `PlatformVersion` and the leaf-level `FeatureVersion` diff --git a/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs b/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs index 375b76c7c66..6991a1eee5c 100644 --- a/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs +++ b/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs @@ -24,7 +24,7 @@ use dpp::state_transition::identity_create_from_shielded_pool_transition::Identi use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; -use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; use dpp::state_transition::unshield_transition::UnshieldTransition; @@ -118,7 +118,7 @@ pub(super) fn orchard_action_count(state_transition_bytes: &[u8]) -> usize { return 0; }; match state_transition { - StateTransition::Shield(ShieldTransition::V0(v0)) => v0.actions.len(), + StateTransition::Shield(shield) => shield.actions().len(), StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0(v0)) => v0.actions.len(), StateTransition::Unshield(UnshieldTransition::V0(v0)) => v0.actions.len(), StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) => { @@ -327,6 +327,9 @@ mod tests { use dpp::consensus::state::state_error::StateError; use dpp::serialization::PlatformSerializable; use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_transition::{ + ShieldTransition, v0::ShieldTransitionV0, v1::ShieldTransitionV1, + }; use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; use std::net::Ipv6Addr; @@ -585,4 +588,59 @@ mod tests { assert_eq!(orchard_action_count(&bytes), 3); assert_eq!(orchard_action_count(&[0xff, 0x00]), 0); } + + #[test] + fn should_meter_failed_shield_broadcasts_in_both_wire_formats() { + let actions = vec![ + SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + 3 + ]; + let legacy = ShieldTransitionV0 { + inputs: Default::default(), + actions, + amount: 1, + anchor: [7; 32], + proof: vec![0; 100], + binding_signature: [0; 64], + fee_strategy: Default::default(), + user_fee_increase: 0, + input_witnesses: vec![], + }; + let bound = ShieldTransitionV1 { + inputs: legacy.inputs.clone(), + actions: legacy.actions.clone(), + amount: legacy.amount, + anchor: legacy.anchor, + proof: legacy.proof.clone(), + binding_signature: legacy.binding_signature, + fee_strategy: legacy.fee_strategy.clone(), + user_fee_increase: legacy.user_fee_increase, + input_witnesses: legacy.input_witnesses.clone(), + }; + for shield in [ShieldTransition::V0(legacy), ShieldTransition::V1(bound)] { + let bytes = StateTransition::Shield(shield) + .serialize_to_bytes() + .unwrap(); + let count = orchard_action_count(&bytes); + assert_eq!( + count, 3, + "both formats reserve their proof work before verification" + ); + let budget = budget(); + let now = Instant::now(); + broadcast(&budget, SOURCE, count, true, now).unwrap(); + assert_eq!( + broadcast(&budget, SOURCE, count, true, now), + Err(ACTION_DRAIN_INTERVAL * 2), + "a new wire format cannot bypass the source's failed-proof budget" + ); + } + } } diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 0ba37f6aff4..358b08bcc27 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1208,8 +1208,11 @@ impl StateTransition { | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::AddressCreditWithdrawal(_) => 11..=LATEST_VERSION, - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) + StateTransition::Shield(st) => match st { + ShieldTransition::V0(_) => 12..=13, + ShieldTransition::V1(_) => 14..=LATEST_VERSION, + }, + StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) => 12..=LATEST_VERSION, // From protocol version 14 the bundle must bind its kind and its asset lock, which diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs index 6cb10c58038..6b4e849dfd2 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs @@ -10,72 +10,84 @@ impl ShieldTransitionAccessorsV0 for ShieldTransition { fn actions(&self) -> &[SerializedAction] { match self { ShieldTransition::V0(v0) => &v0.actions, + ShieldTransition::V1(v1) => &v1.actions, } } fn set_actions(&mut self, actions: Vec) { match self { ShieldTransition::V0(v0) => v0.actions = actions, + ShieldTransition::V1(v1) => v1.actions = actions, } } fn amount(&self) -> u64 { match self { ShieldTransition::V0(v0) => v0.amount, + ShieldTransition::V1(v1) => v1.amount, } } fn set_amount(&mut self, amount: u64) { match self { ShieldTransition::V0(v0) => v0.amount = amount, + ShieldTransition::V1(v1) => v1.amount = amount, } } fn anchor(&self) -> [u8; 32] { match self { ShieldTransition::V0(v0) => v0.anchor, + ShieldTransition::V1(v1) => v1.anchor, } } fn set_anchor(&mut self, anchor: [u8; 32]) { match self { ShieldTransition::V0(v0) => v0.anchor = anchor, + ShieldTransition::V1(v1) => v1.anchor = anchor, } } fn proof(&self) -> &[u8] { match self { ShieldTransition::V0(v0) => &v0.proof, + ShieldTransition::V1(v1) => &v1.proof, } } fn set_proof(&mut self, proof: Vec) { match self { ShieldTransition::V0(v0) => v0.proof = proof, + ShieldTransition::V1(v1) => v1.proof = proof, } } fn binding_signature(&self) -> [u8; 64] { match self { ShieldTransition::V0(v0) => v0.binding_signature, + ShieldTransition::V1(v1) => v1.binding_signature, } } fn set_binding_signature(&mut self, binding_signature: [u8; 64]) { match self { ShieldTransition::V0(v0) => v0.binding_signature = binding_signature, + ShieldTransition::V1(v1) => v1.binding_signature = binding_signature, } } fn fee_strategy(&self) -> &AddressFundsFeeStrategy { match self { ShieldTransition::V0(v0) => &v0.fee_strategy, + ShieldTransition::V1(v1) => &v1.fee_strategy, } } fn set_fee_strategy(&mut self, fee_strategy: AddressFundsFeeStrategy) { match self { ShieldTransition::V0(v0) => v0.fee_strategy = fee_strategy, + ShieldTransition::V1(v1) => v1.fee_strategy = fee_strategy, } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs index 219d9415b19..c68ca3af834 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs @@ -16,7 +16,10 @@ use crate::state_transition::shield_transition::ShieldTransition; #[cfg(feature = "state-transition-signing")] use crate::{ prelude::{AddressNonce, UserFeeIncrease}, - state_transition::{shield_transition::v0::ShieldTransitionV0, StateTransition}, + state_transition::{ + shield_transition::{v0::ShieldTransitionV0, v1::ShieldTransitionV1}, + StateTransition, + }, ProtocolError, }; #[cfg(feature = "state-transition-signing")] @@ -57,9 +60,24 @@ impl ShieldTransitionMethodsV0 for ShieldTransition { ) .await } + 1 => { + ShieldTransitionV1::try_from_bundle_with_signer( + inputs, + actions, + amount, + anchor, + proof, + binding_signature, + fee_strategy, + signer, + user_fee_increase, + platform_version, + ) + .await + } version => Err(ProtocolError::UnknownVersionMismatch { method: "ShieldTransition::try_from_bundle_with_signer".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs index 123a87060bb..fa7adc3cfbb 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs @@ -4,13 +4,17 @@ mod state_transition_estimated_fee_validation; mod state_transition_like; mod state_transition_validation; pub mod v0; +pub mod v1; mod version; use crate::state_transition::shield_transition::v0::ShieldTransitionV0; use crate::state_transition::shield_transition::v0::ShieldTransitionV0Signable; +use crate::state_transition::shield_transition::v1::{ + ShieldTransitionV1, ShieldTransitionV1Signable, +}; use crate::state_transition::StateTransitionFieldTypes; -pub type ShieldTransitionLatest = ShieldTransitionV0; +pub type ShieldTransitionLatest = ShieldTransitionV1; use crate::identity::state_transition::OptionallyAssetLockProved; #[cfg(feature = "json-conversion")] @@ -58,6 +62,9 @@ use serde::{Deserialize, Serialize}; pub enum ShieldTransition { #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] V0(ShieldTransitionV0), + /// Binds the bundle to its kind and funding addresses. Admitted only from protocol version 14. + #[cfg_attr(feature = "serde-conversion", serde(rename = "1"))] + V1(ShieldTransitionV1), } impl OptionallyAssetLockProved for ShieldTransition {} @@ -85,9 +92,16 @@ impl StateTransitionFieldTypes for ShieldTransition { pub(crate) mod json_convertible_tests { use super::*; use crate::address_funds::{AddressFundsFeeStrategyStep, AddressWitness, PlatformAddress}; + use crate::dashcore::hashes::{sha256, Hash}; + use crate::serialization::{ + JsonConvertible, PlatformDeserializableUntrusted, PlatformSerializable, Signable, + ValueConvertible, + }; use crate::shielded::SerializedAction; use crate::state_transition::shield_transition::v0::ShieldTransitionV0; + use crate::state_transition::StateTransition; use platform_value::{platform_value, BinaryData, Bytes32}; + use platform_version::version::PlatformVersion; use serde_json::json; use std::collections::BTreeMap; @@ -120,6 +134,118 @@ pub(crate) mod json_convertible_tests { }) } + fn bound_fixture() -> ShieldTransition { + let ShieldTransition::V0(v0) = fixture() else { + panic!("historical fixture"); + }; + ShieldTransition::V1(ShieldTransitionV1 { + inputs: v0.inputs, + actions: v0.actions, + amount: v0.amount, + anchor: v0.anchor, + proof: v0.proof, + binding_signature: v0.binding_signature, + fee_strategy: v0.fee_strategy, + user_fee_increase: v0.user_fee_increase, + input_witnesses: v0.input_witnesses, + }) + } + + #[test] + fn should_separate_shield_formats_at_protocol_activation() { + for (pv, format) in [ + (PlatformVersion::get(12).expect("PV12"), 0), + (PlatformVersion::get(13).expect("PV13"), 0), + (PlatformVersion::latest(), 1), + ] { + let bounds = &pv + .dpp + .state_transition_serialization_versions + .shield_state_transition; + assert_eq!( + ( + bounds.min_version, + bounds.max_version, + bounds.default_current_version + ), + (format, format, format) + ); + for shield in [fixture(), bound_fixture()] { + let supported = shield.feature_version() == format; + let transition = StateTransition::Shield(shield); + let bytes = transition.serialize_to_bytes().expect("wire bytes"); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted(&bytes) + .expect("generic decoding"), + transition + ); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_exact_in_version(&bytes, pv) + .is_ok(), + supported + ); + } + } + } + + #[test] + fn should_include_the_shield_format_in_address_signing_bytes() { + let legacy = StateTransition::Shield(fixture()); + let bound = StateTransition::Shield(bound_fixture()); + for (before, after) in [ + ( + legacy.serialize_to_bytes().expect("legacy"), + bound.serialize_to_bytes().expect("bound"), + ), + ( + legacy.signable_bytes().expect("legacy signing bytes"), + bound.signable_bytes().expect("bound signing bytes"), + ), + ] { + assert_eq!(before.len(), after.len()); + assert_eq!( + before.iter().zip(&after).filter(|(a, b)| a != b).count(), + 1, + "identical fields must differ only in the signed format discriminant" + ); + } + } + + #[test] + fn should_round_trip_bound_shields_without_losing_the_format() { + let original = bound_fixture(); + let json = original.to_json().expect("JSON"); + assert_eq!(json["$formatVersion"], "1"); + assert_eq!( + ShieldTransition::from_json(json).expect("JSON round trip"), + original + ); + let value = original.to_object().expect("Value"); + assert_eq!( + ShieldTransition::from_object(value).expect("Value round trip"), + original + ); + } + + #[test] + fn should_preserve_historical_shield_wire_and_signable_bytes() { + let transition = StateTransition::Shield(fixture()); + let wire = transition + .serialize_to_bytes() + .expect("historical wire bytes"); + let signable = transition + .signable_bytes() + .expect("historical signing bytes"); + assert_eq!( + sha256::Hash::hash(&wire).to_string(), + "cb3bd0211bf65d650f543cd26c41e8ea8a3f65966a7d724da0dbb84c6d954171" + ); + assert_eq!( + sha256::Hash::hash(&signable).to_string(), + "e4d9d6efea0f33c18b114c9250322c190af2eec35d1478ebadb59db243a6b45a" + ); + } + #[test] fn json_round_trip_with_full_wire_shape() { use crate::serialization::JsonConvertible; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs index 06c7d5c4e33..483df436f79 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs @@ -1,5 +1,6 @@ use crate::fee::Credits; use crate::shielded::compute_shielded_verification_fee; +use crate::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use crate::state_transition::shield_transition::ShieldTransition; use crate::state_transition::StateTransitionEstimatedFeeValidation; use crate::ProtocolError; @@ -29,7 +30,6 @@ impl StateTransitionEstimatedFeeValidation for ShieldTransition { ) -> Result { // The on-wire Orchard `actions` count is what the compute fee is priced against (matching // the consensus structure-validation floor and the execution-event compute charge). - let ShieldTransition::V0(v0) = self; - compute_shielded_verification_fee(v0.actions.len(), platform_version) + compute_shielded_verification_fee(self.actions().len(), platform_version) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs index 017a143bc2e..9a40fb7c096 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs @@ -13,24 +13,28 @@ impl StateTransitionLike for ShieldTransition { fn modified_data_ids(&self) -> Vec { match self { ShieldTransition::V0(transition) => transition.modified_data_ids(), + ShieldTransition::V1(transition) => transition.modified_data_ids(), } } fn state_transition_protocol_version(&self) -> FeatureVersion { match self { ShieldTransition::V0(_) => 0, + ShieldTransition::V1(_) => 1, } } /// returns the type of State Transition fn state_transition_type(&self) -> StateTransitionType { match self { ShieldTransition::V0(transition) => transition.state_transition_type(), + ShieldTransition::V1(transition) => transition.state_transition_type(), } } fn unique_identifiers(&self) -> Vec { match self { ShieldTransition::V0(transition) => transition.unique_identifiers(), + ShieldTransition::V1(transition) => transition.unique_identifiers(), } } } @@ -40,12 +44,14 @@ impl StateTransitionHasUserFeeIncrease for ShieldTransition { fn user_fee_increase(&self) -> UserFeeIncrease { match self { ShieldTransition::V0(transition) => transition.user_fee_increase(), + ShieldTransition::V1(transition) => transition.user_fee_increase(), } } /// set a fee multiplier fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { match self { ShieldTransition::V0(transition) => transition.set_user_fee_increase(user_fee_increase), + ShieldTransition::V1(transition) => transition.set_user_fee_increase(user_fee_increase), } } } @@ -59,6 +65,7 @@ impl StateTransitionWitnessSigned for ShieldTransition { > { match self { ShieldTransition::V0(transition) => transition.inputs(), + ShieldTransition::V1(transition) => transition.inputs(), } } @@ -70,6 +77,7 @@ impl StateTransitionWitnessSigned for ShieldTransition { > { match self { ShieldTransition::V0(transition) => transition.inputs_mut(), + ShieldTransition::V1(transition) => transition.inputs_mut(), } } @@ -82,18 +90,21 @@ impl StateTransitionWitnessSigned for ShieldTransition { ) { match self { ShieldTransition::V0(transition) => transition.set_inputs(inputs), + ShieldTransition::V1(transition) => transition.set_inputs(inputs), } } fn witnesses(&self) -> &Vec { match self { ShieldTransition::V0(transition) => transition.witnesses(), + ShieldTransition::V1(transition) => transition.witnesses(), } } fn set_witnesses(&mut self, witnesses: Vec) { match self { ShieldTransition::V0(transition) => transition.set_witnesses(witnesses), + ShieldTransition::V1(transition) => transition.set_witnesses(witnesses), } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs index 71d92296f55..83156bab69e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs @@ -12,6 +12,7 @@ impl StateTransitionStructureValidation for ShieldTransition { ) -> SimpleConsensusValidationResult { match self { ShieldTransition::V0(v0) => v0.validate_structure(platform_version), + ShieldTransition::V1(v1) => v1.validate_structure(platform_version), } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs new file mode 100644 index 00000000000..6d2f2380d91 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs @@ -0,0 +1,70 @@ +mod state_transition_like; +mod state_transition_validation; +mod types; +#[cfg(feature = "state-transition-signing")] +pub(super) mod v1_methods; +mod version; + +use std::collections::BTreeMap; + +use crate::address_funds::{AddressFundsFeeStrategy, AddressWitness, PlatformAddress}; +use crate::fee::Credits; +use crate::prelude::{AddressNonce, UserFeeIncrease}; +use crate::shielded::SerializedAction; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, PlatformSignable, +}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// The fields of version 0, with a signed format discriminant separating the +/// funding-bound bundle domain from the unbound bundles of protocol versions 12 and 13. +/// Version 0 is refused without charging once protocol version 14 activates. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PlatformSignable, + PartialEq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[platform_serialize(unversioned)] +pub struct ShieldTransitionV1 { + /// Address inputs funding the shield (address -> nonce + max contribution). + /// The total across all inputs must cover |value_balance| + fees. + /// Excess credits remain in the source addresses. + #[cfg_attr( + feature = "json-conversion", + serde(with = "crate::address_funds::serde_helpers::address_input_map") + )] + pub inputs: BTreeMap, + /// Orchard actions (spend-output pairs) + pub actions: Vec, + /// Amount of credits being shielded (entering the shielded pool). + pub amount: u64, + /// Sinsemilla root of the note commitment tree (Orchard Anchor) + pub anchor: [u8; 32], + /// Halo2 proof bytes + pub proof: Vec, + /// RedPallas binding signature + pub binding_signature: [u8; 64], + /// Fee payment strategy + pub fee_strategy: AddressFundsFeeStrategy, + /// Fee multiplier + pub user_fee_increase: UserFeeIncrease, + /// Address witness signatures (excluded from sig hash) + #[platform_signable(exclude_from_sig_hash)] + pub input_witnesses: Vec, +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs new file mode 100644 index 00000000000..b9eaf4a61b0 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs @@ -0,0 +1,78 @@ +use crate::address_funds::{AddressWitness, PlatformAddress}; +use crate::fee::Credits; +use crate::prelude::{AddressNonce, UserFeeIncrease}; +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::shield_transition::ShieldTransition; +use crate::state_transition::StateTransitionHasUserFeeIncrease; +use crate::{ + prelude::Identifier, + state_transition::{StateTransitionLike, StateTransitionType}, +}; + +use crate::state_transition::StateTransitionType::Shield; +use crate::state_transition::{StateTransition, StateTransitionWitnessSigned}; +use crate::version::FeatureVersion; +use std::collections::BTreeMap; + +impl From for StateTransition { + fn from(value: ShieldTransitionV1) -> Self { + let shield_transition: ShieldTransition = value.into(); + shield_transition.into() + } +} + +impl StateTransitionLike for ShieldTransitionV1 { + fn state_transition_protocol_version(&self) -> FeatureVersion { + 1 + } + + /// returns the type of State Transition + fn state_transition_type(&self) -> StateTransitionType { + Shield + } + + /// Returns IDs of modified data (none for shielded transitions) + fn modified_data_ids(&self) -> Vec { + vec![] + } + + /// State transitions with the same inputs should not be allowed to overlap + fn unique_identifiers(&self) -> Vec { + self.inputs + .iter() + .map(|(key, (nonce, _))| key.base64_string_with_nonce(*nonce)) + .collect() + } +} + +impl StateTransitionHasUserFeeIncrease for ShieldTransitionV1 { + fn user_fee_increase(&self) -> UserFeeIncrease { + self.user_fee_increase + } + + fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { + self.user_fee_increase = user_fee_increase + } +} + +impl StateTransitionWitnessSigned for ShieldTransitionV1 { + fn inputs(&self) -> &BTreeMap { + &self.inputs + } + + fn inputs_mut(&mut self) -> &mut BTreeMap { + &mut self.inputs + } + + fn set_inputs(&mut self, inputs: BTreeMap) { + self.inputs = inputs; + } + + fn witnesses(&self) -> &Vec { + &self.input_witnesses + } + + fn set_witnesses(&mut self, witnesses: Vec) { + self.input_witnesses = witnesses; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs new file mode 100644 index 00000000000..defcdb32b4b --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs @@ -0,0 +1,250 @@ +use crate::address_funds::AddressFundsFeeStrategyStep; +use crate::consensus::basic::state_transition::{ + FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, + FeeStrategyTooManyStepsError, InputBelowMinimumError, InputWitnessCountMismatchError, + ShieldedInvalidValueBalanceError, TransitionNoInputsError, +}; +use crate::consensus::basic::BasicError; +use crate::shielded::compute_shielded_verification_fee; +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::state_transitions::shielded::common_validation::{ + validate_actions_count, validate_anchor_not_zero, validate_encrypted_note_sizes, + validate_proof_not_empty, +}; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; +use std::collections::HashSet; + +impl StateTransitionStructureValidation for ShieldTransitionV1 { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + // Actions count must be in [1, max] + let result = validate_actions_count( + &self.actions, + platform_version + .system_limits + .max_shielded_transition_actions, + ); + if !result.is_valid() { + return result; + } + + // Each action's encrypted_note must be exactly ENCRYPTED_NOTE_SIZE bytes + let result = validate_encrypted_note_sizes(&self.actions); + if !result.is_valid() { + return result; + } + + // Inputs must not be empty (shield requires address funding) + if self.inputs.is_empty() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::TransitionNoInputsError(TransitionNoInputsError::new()).into(), + ); + } + + // Input witnesses must match inputs count + if self.inputs.len() != self.input_witnesses.len() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::InputWitnessCountMismatchError(InputWitnessCountMismatchError::new( + self.inputs.len().min(u16::MAX as usize) as u16, + self.input_witnesses.len().min(u16::MAX as usize) as u16, + )) + .into(), + ); + } + + // Validate each input amount is > 0 + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; + for (_nonce, amount) in self.inputs.values() { + if *amount < min_input_amount { + return SimpleConsensusValidationResult::new_with_error( + BasicError::InputBelowMinimumError(InputBelowMinimumError::new( + *amount, + min_input_amount, + )) + .into(), + ); + } + } + + // amount must be positive (credits flowing into pool) + if self.amount == 0 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount must be greater than zero".to_string(), + ), + ) + .into(), + ); + } + + // 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(), + ); + } + + // Total input amounts must cover the shield amount PLUS the shielded COMPUTE fee + // (`compute_shielded_verification_fee`: proof verification + per-action processing, NO storage + // term). This is a stateless lower bound: the real per-action storage cost is metered by + // GroveDB at execution and is unknowable here, so we deliberately do NOT add the 312-byte + // storage ESTIMATE — doing so would falsely reject otherwise-valid transitions whose actual + // metered storage is below the estimate. + // + // This is a cheap early reject. The AUTHORITATIVE funding gate is `validate_fees_of_event` + // (drive-abci), which re-checks `metered_storage + metered_processing + compute_fee` against + // the per-input balances AFTER the shield-amount reallocation. Anti-mint safety does NOT + // depend on this `+fee` term: it depends on `Σrequested >= amount` enforced by the + // reallocation plus that authoritative gate. `input_sum` here only bounds the sum of max + // contributions. + // + // `compute_shielded_verification_fee` returns a `Result`, but this validator returns a + // `SimpleConsensusValidationResult`, so we cannot `?`-propagate; we map an overflow to a + // consensus error (reachable only via pathological fee constants). + let minimum_fee = + match compute_shielded_verification_fee(self.actions.len(), platform_version) { + Ok(fee) => fee, + Err(_) => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "minimum shielded fee computation overflowed".to_string(), + ), + ) + .into(), + ); + } + }; + let required_input = match self.amount.checked_add(minimum_fee) { + Some(required) => required, + None => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount plus minimum shielded fee overflows".to_string(), + ), + ) + .into(), + ); + } + }; + let input_sum = self + .inputs + .values() + .try_fold(0u64, |acc, (_, amount)| acc.checked_add(*amount)); + match input_sum { + Some(sum) if sum >= required_input => {} + Some(sum) => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new(format!( + "total input amount ({}) is less than shield amount ({}) plus minimum shielded fee ({})", + sum, self.amount, minimum_fee + )), + ) + .into(), + ); + } + None => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "total input amounts overflow".to_string(), + ), + ) + .into(), + ); + } + } + + // Proof must not be empty + let result = validate_proof_not_empty(&self.proof); + if !result.is_valid() { + return result; + } + + // Anchor must not be all zeros + let result = validate_anchor_not_zero(&self.anchor); + if !result.is_valid() { + return result; + } + + // Fee strategy validation (reuse address funds patterns) + if self.fee_strategy.is_empty() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyEmptyError(FeeStrategyEmptyError::new()).into(), + ); + } + + let max_fee_strategies = platform_version + .dpp + .state_transitions + .max_address_fee_strategies as usize; + if self.fee_strategy.len() > max_fee_strategies { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyTooManyStepsError(FeeStrategyTooManyStepsError::new( + self.fee_strategy.len().min(u8::MAX as usize) as u8, + max_fee_strategies.min(u8::MAX as usize) as u8, + )) + .into(), + ); + } + + let mut seen = HashSet::with_capacity(self.fee_strategy.len()); + for step in &self.fee_strategy { + if !seen.insert(step) { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyDuplicateError(FeeStrategyDuplicateError::new()).into(), + ); + } + + // Reject structurally-unusable fee-strategy steps here — cheaply, before Orchard proof + // verification — rather than letting an out-of-range/no-op step slip through and only + // surface later as a generic `AddressesNotEnoughFundsError`. A `Shield` has transparent + // inputs but NO transparent outputs, so `DeductFromInput` must index a real input and + // `ReduceOutput` can never apply. Mirrors `AddressFundingFromAssetLock`'s validator. + match step { + AddressFundsFeeStrategyStep::DeductFromInput(index) => { + if *index as usize >= self.inputs.len() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyIndexOutOfBoundsError( + FeeStrategyIndexOutOfBoundsError::new( + "DeductFromInput", + *index, + self.inputs.len().min(u16::MAX as usize) as u16, + ), + ) + .into(), + ); + } + } + AddressFundsFeeStrategyStep::ReduceOutput(index) => { + // A Shield has no transparent outputs (count 0), so any `ReduceOutput` is out of + // bounds. + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyIndexOutOfBoundsError( + FeeStrategyIndexOutOfBoundsError::new("ReduceOutput", *index, 0), + ) + .into(), + ); + } + } + } + + SimpleConsensusValidationResult::new() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs new file mode 100644 index 00000000000..39f36cb0ef0 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs @@ -0,0 +1,16 @@ +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::StateTransitionFieldTypes; + +impl StateTransitionFieldTypes for ShieldTransitionV1 { + fn signature_property_paths() -> Vec<&'static str> { + vec![] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs new file mode 100644 index 00000000000..8ee23cf1493 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs @@ -0,0 +1,65 @@ +#[cfg(feature = "state-transition-signing")] +use std::collections::BTreeMap; + +#[cfg(feature = "state-transition-signing")] +use crate::address_funds::{AddressFundsFeeStrategy, AddressWitness, PlatformAddress}; +#[cfg(feature = "state-transition-signing")] +use crate::fee::Credits; +#[cfg(feature = "state-transition-signing")] +use crate::identity::signer::Signer; +#[cfg(feature = "state-transition-signing")] +use crate::serialization::Signable; +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +use crate::state_transition::shield_transition::methods::ShieldTransitionMethodsV0; +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +#[cfg(feature = "state-transition-signing")] +use crate::{ + prelude::{AddressNonce, UserFeeIncrease}, + state_transition::StateTransition, + ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl ShieldTransitionMethodsV0 for ShieldTransitionV1 { + #[cfg(feature = "state-transition-signing")] + async fn try_from_bundle_with_signer>( + inputs: BTreeMap, + actions: Vec, + amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + user_fee_increase: UserFeeIncrease, + _platform_version: &PlatformVersion, + ) -> Result { + // Create the unsigned transition (empty witnesses) + let mut shield_transition = ShieldTransitionV1 { + inputs: inputs.clone(), + actions, + amount, + anchor, + proof, + binding_signature, + fee_strategy, + user_fee_increase, + input_witnesses: Vec::new(), + }; + + // Compute signable bytes (excludes input_witnesses which are marked exclude_from_sig_hash) + let state_transition: StateTransition = shield_transition.clone().into(); + let signable_bytes = state_transition.signable_bytes()?; + + // Sign each input address + let mut input_witnesses: Vec = Vec::with_capacity(inputs.len()); + for address in inputs.keys() { + input_witnesses.push(signer.sign_create_witness(address, &signable_bytes).await?); + } + shield_transition.input_witnesses = input_witnesses; + + Ok(shield_transition.into()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs new file mode 100644 index 00000000000..808a3f97797 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs @@ -0,0 +1,9 @@ +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for ShieldTransitionV1 { + fn feature_version(&self) -> FeatureVersion { + 1 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs index 8f5de0b6b7a..bb289aff5b3 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs @@ -6,6 +6,7 @@ impl FeatureVersioned for ShieldTransition { fn feature_version(&self) -> FeatureVersion { match self { ShieldTransition::V0(v0) => v0.feature_version(), + ShieldTransition::V1(v1) => v1.feature_version(), } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs index 25450b2cca1..21054e8760b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs @@ -1,6 +1,7 @@ /// Processor traits. pub mod traits; pub(crate) mod v0; +pub(crate) mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -51,9 +52,16 @@ pub(in crate::execution) fn process_state_transition<'a, C: CoreRPCLike>( transaction, platform_version, ), + 1 => v1::process_state_transition_v1( + platform, + block_info, + state_transition, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "process_state_transition".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } 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 index 6be3f347c16..0312c8619c1 100644 --- 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 @@ -17,6 +17,8 @@ use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlr use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::serialization::{PlatformMessageSignable, Signable}; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; @@ -162,11 +164,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { fn shielded_proof_action_count(&self) -> usize { match self { - StateTransition::Shield(st) => match st { - dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { - v0.actions.len() - } - }, + StateTransition::Shield(st) => st.actions().len(), StateTransition::ShieldFromIdentity(st) => match st { ShieldFromIdentityTransition::V0(v0) => v0.actions.len(), }, @@ -744,19 +742,16 @@ fn validate_shielded_proof_v0( } let result = match state_transition { - 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 - ) - } - }, + // The accessors project the same V0 fields; its historical empty preimage is unchanged. + StateTransition::Shield(st) => reconstruct_and_verify_bundle( + st.actions(), + FLAGS_OUTPUTS_ONLY, + -(st.amount() as i64), + &st.anchor(), + st.proof(), + &st.binding_signature(), + &[], + ), StateTransition::ShieldFromIdentity(st) => match st { ShieldFromIdentityTransition::V0(v0) => reconstruct_and_verify_bundle( &v0.actions, @@ -1233,20 +1228,18 @@ fn validate_shielded_proof_v1( // The credit pool's outputs-only bundles carry no anchor, so the proved // bytes verify wherever they land. Their preimage binds the kind and the // funder, which a copy re-wrapped under someone else's funding cannot match. - StateTransition::Shield(st) => match st { - dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { - let extra_sighash_data = - shield_extra_sighash_data(&v0.inputs, platform_version)?; - reconstruct_and_verify_bundle( - &v0.actions, - FLAGS_OUTPUTS_ONLY, - -(v0.amount as i64), - &v0.anchor, - v0.proof.as_slice(), - &v0.binding_signature, - &extra_sighash_data, - ) - } + StateTransition::Shield(st) => { + let extra_sighash_data = + shield_extra_sighash_data(st.inputs(), platform_version)?; + reconstruct_and_verify_bundle( + st.actions(), + FLAGS_OUTPUTS_ONLY, + -(st.amount() as i64), + &st.anchor(), + st.proof(), + &st.binding_signature(), + &extra_sighash_data, + ) }, // CheckTx's admission check. Block processing verifies the same bundle in // `ShieldFromIdentity`'s transform, which must rebuild the same preimage. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs new file mode 100644 index 00000000000..6159bc274b6 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs @@ -0,0 +1,387 @@ +use crate::error::Error; +use crate::execution::types::execution_event::ExecutionEvent; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::processor::address_balances_and_nonces::StateTransitionAddressBalancesAndNoncesValidation; +use crate::execution::validation::state_transition::processor::address_witnesses::{ + StateTransitionAddressWitnessValidationV0, StateTransitionHasAddressWitnessValidationV0, +}; +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::advanced_structure_without_state::StateTransitionAdvancedStructureValidationV0; +use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; +use crate::execution::validation::state_transition::processor::identity_balance::StateTransitionIdentityBalanceValidationV0; +use crate::execution::validation::state_transition::processor::identity_based_signature::StateTransitionIdentityBasedSignatureValidationV0; +use crate::execution::validation::state_transition::processor::identity_nonces::{ + StateTransitionHasIdentityNonceValidationV0, StateTransitionIdentityNonceValidationV0, +}; +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::StateTransitionSignerAwareActionTransformer; +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::block::block_info::BlockInfo; +use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransition; +use dpp::version::{DefaultForPlatformVersion, PlatformVersion}; +use dpp::ProtocolError; +use drive::grovedb::TransactionArg; + +/// Version 0's validation pipeline, with Shield proof verification deferred to its +/// action transformer after authentication and input balance/nonce checks. This +/// lets a funded proof failure produce a fee-paying nonce-bump action instead of +/// returning an unpaid refusal in the shared proof step. Legacy Shield formats are +/// refused before authentication; other transitions keep their existing validation path. +pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( + platform: &'a PlatformRef, + block_info: &BlockInfo, + state_transition: StateTransition, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result>, Error> { + // A legacy signature did not authorize the bound proof domain or its failure fee. + // Refuse before authentication so direct, already-decoded callers cannot charge it. + if matches!( + &state_transition, + StateTransition::Shield(ShieldTransition::V0(_)) + ) { + return Ok(ConsensusValidationResult::new_with_error( + StateTransitionNotActiveError::new( + state_transition.name(), + platform_version.protocol_version, + *state_transition.active_version_range().end(), + ) + .into(), + )); + } + + let mut state_transition_execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version)?; + + if state_transition.has_is_allowed_validation()? { + let result = state_transition.validate_is_allowed(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) + // As we already have removed identity create above, it just splits between identity top up (below - false) and + // all other state transitions (above - true) + let result = if state_transition.validates_signature_based_on_identity_info() { + state_transition.validate_identity_signed_state_transition( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + ) + } else { + // Currently only identity top up and identity top up from addresses uses this, + // We will add the cost for a balance retrieval + state_transition.retrieve_identity_info( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + ) + }?; + if !result.is_valid() { + // If the signature is not valid or if we could not retrieve identity info + // we do not have the user pay for the state transition. + // Since it is most likely not from them + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + Some(result.into_data()?) + } else { + // Currently only identity create + None + }; + + if state_transition.has_address_witness_validation(platform_version)? { + let result = state_transition.validate_address_witnesses( + &mut state_transition_execution_context, + platform_version, + )?; + if !result.is_valid() { + // If the witnesses are not valid + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Start by validating addresses if the transition has input addresses + let remaining_address_balances = if state_transition + .has_addresses_balances_and_nonces_validation() + { + // Here we validate that all input addresses have enough balance + // We also validate that nonces are bumped + let result = state_transition.validate_address_balances_and_nonces( + platform.drive, + &mut state_transition_execution_context, + transaction, + platform_version, + )?; + if !result.is_valid() { + // The nonces are not valid or there is not enough balance. The transaction is each replaying an input or there + // isn't enough balance, either way the transaction should be rejected. + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + Some(result.into_data()?) + } else { + None + }; + + // Only identity top up and identity create do not have nonces validation + if state_transition.has_identity_nonce_validation(platform_version)? { + // Validating identity contract nonce, this must happen after validating the signature + let result = state_transition.validate_identity_nonces( + &platform.into(), + platform.state.last_block_info(), + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !result.is_valid() { + // If the nonce is not valid the state transition is not paid for, most likely because + // this is just a replayed block + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Only Data contract state transitions and Masternode vote do not have basic structure validation + if state_transition.has_basic_structure_validation(platform_version) { + // We validate basic structure validation after verifying the identity, + // this is structure validation that does not require state and is already checked on check_tx + let consensus_result = + state_transition.validate_basic_structure(platform.config.network, platform_version)?; + + if !consensus_result.is_valid() { + // Basic structure validation is extremely cheap to process, because of this attacks are + // not likely. + // Often the basic structure validation is necessary for estimated costs + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok( + ConsensusValidationResult::::new_with_errors( + consensus_result.errors, + ), + ); + } + } + + // For identity credit withdrawal and identity credit transfers we have a balance pre-check that includes a + // processing amount and the transfer amount. + // For other state transitions we only check a min balance for an amount set per version. + // This is not done for identity create and identity top up who don't have this check here + if state_transition.has_identity_minimum_balance_pre_check_validation() { + // Validating that we have sufficient balance for a transfer or withdrawal, + // this must happen after validating the signature + + let identity = maybe_identity + .as_mut() + .ok_or(ProtocolError::CorruptedCodeExecution( + "identity must be known to validate the balance".to_string(), + ))?; + let result = state_transition + .validate_identity_minimum_balance_pre_check(identity, platform_version)?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // For address-based state transitions that transfer or withdraw, we have a balance pre-check + // that validates addresses have enough remaining balance after the input amounts to cover fees. + if state_transition.has_addresses_minimum_balance_pre_check_validation() { + // Validating that addresses have sufficient remaining balance for fees, + // this must happen after validating the address balances and nonces + + let address_balances = + remaining_address_balances + .as_ref() + .ok_or(ProtocolError::CorruptedCodeExecution( + "address balances must be known to validate the minimum balance".to_string(), + ))?; + let result = state_transition + .validate_addresses_minimum_balance_pre_check(address_balances, platform_version)?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // A masternode vote is paid by its vote poll's prefunded specialized balance, never by the + // voter. When that fund does not exist or cannot cover the vote, nobody can be charged for + // the vote, so it is refused unpaid: proposers strip it from their block and other + // validators reject a block that carries it, exactly like a vote that fails its nonce check. + // Until 4.2 the pre-check ran here but its result was never read, and such a vote failed + // inside execution, when its cost was deducted, as an internal error; both outcomes keep the + // vote out of every block and no chain ever held one, so acting on it is not versioned. + if state_transition.uses_prefunded_specialized_balance_for_payment() { + let result = state_transition.validate_minimum_prefunded_specialized_balance_pre_check( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // 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)); + } + } + + // Shield verifies its proof in its transform so an authenticated failure can pay + // fees and consume the transparent input nonces. Pool-paid proofs stay unpaid. + if state_transition.has_shielded_proof_validation() + && !matches!(&state_transition, StateTransition::Shield(_)) + { + 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 + // Next we have advanced structure validation, this is structure validation that does not require + // state but isn't checked on check_tx. If advanced structure fails identity nonces or identity + // contract nonces will be bumped + let identity = maybe_identity + .as_ref() + .ok_or(ProtocolError::CorruptedCodeExecution( + "the identity should always be known on advanced structure validation".to_string(), + ))?; + let consensus_result = state_transition.validate_advanced_structure( + identity, + &mut state_transition_execution_context, + platform_version, + )?; + + if !consensus_result.is_valid() { + return consensus_result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }); + } + } + + // Identity create, documents batch and masternode vote all have advanced structure validation with state + let action = if state_transition.has_advanced_structure_validation_with_state() { + // Currently used for identity create and documents batch + let state_transition_action_result = state_transition.transform_into_action_for_signer( + platform, + block_info, + &remaining_address_balances, + maybe_identity.as_ref(), + ValidationMode::Validator, + &mut state_transition_execution_context, + transaction, + )?; + if !state_transition_action_result.is_valid_with_data() { + return state_transition_action_result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }); + } + let action = state_transition_action_result.into_data()?; + + // Validating structure + let result = state_transition.validate_advanced_structure_from_state( + block_info, + platform.config.network, + &action, + maybe_identity.as_ref(), + &mut state_transition_execution_context, + platform_version, + )?; + if !result.is_valid() { + return result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }); + } + + Some(action) + } else { + None + }; + + // Validating state + // Only identity Top up does not validate state and instead just returns the action for topping up + let result = if state_transition.has_state_validation() { + state_transition.validate_state( + action, + platform, + ValidationMode::Validator, + block_info, + &mut state_transition_execution_context, + transaction, + )? + } else if let Some(action) = action { + ConsensusValidationResult::new_with_data(action) + } else { + state_transition.transform_into_action_for_signer( + platform, + block_info, + &remaining_address_balances, + maybe_identity.as_ref(), + ValidationMode::Validator, + &mut state_transition_execution_context, + transaction, + )? + }; + + result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }) +} 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 index ec20133669c..4033422a757 100644 --- 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 @@ -17,6 +17,8 @@ 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::execution::validation::state_transition::shield::transform_into_action::v1::ShieldStateTransitionTransformIntoActionValidationV1; +use crate::execution::validation::state_transition::shield::transform_into_action::v2::ShieldStateTransitionTransformIntoActionValidationV2; +use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -30,6 +32,7 @@ pub trait StateTransitionShieldTransitionActionTransformer { platform: &PlatformRef, inputs_with_remaining_balance: BTreeMap, block_info: &BlockInfo, + validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -41,6 +44,7 @@ impl StateTransitionShieldTransitionActionTransformer for ShieldTransition { platform: &PlatformRef, inputs_with_remaining_balance: BTreeMap, block_info: &BlockInfo, + validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -69,9 +73,20 @@ impl StateTransitionShieldTransitionActionTransformer for ShieldTransition { execution_context, platform_version, ), + 2 => self.transform_into_action_v2( + platform.drive, + tx, + inputs_with_remaining_balance, + block_info, + validation_mode, + platform.state.last_committed_block_epoch_ref(), + platform.state.previous_fee_versions(), + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "shield transition: transform_into_action".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], 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 index b340786fd7a..d80c354ff6d 100644 --- 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 @@ -23,7 +23,8 @@ mod tests { use dpp::prelude::AddressNonce; use dpp::serialization::{PlatformSerializable, Signable}; use dpp::shielded::{shield_extra_sighash_data, SerializedAction}; - use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::shield_transition::methods::ShieldTransitionMethodsV0; + use dpp::state_transition::shield_transition::v1::ShieldTransitionV1; use dpp::state_transition::shield_transition::ShieldTransition; use dpp::state_transition::StateTransition; use grovedb_commitment_tree::{ @@ -38,7 +39,7 @@ mod tests { // Helper Functions (transition-specific) // ========================================== - /// Builds a raw `ShieldTransitionV0` with dummy witnesses. Used for structure validation tests + /// Builds a raw `ShieldTransitionV1` 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( @@ -52,7 +53,7 @@ mod tests { ) -> StateTransition { let witnesses: Vec = (0..witness_count).map(|_| create_dummy_witness()).collect(); - StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs, actions, amount, @@ -65,7 +66,7 @@ mod tests { })) } - /// Builds a `ShieldTransitionV0` and signs it with the provided signer. + /// Builds a `ShieldTransitionV1` and signs it with the provided signer. /// The transition will have valid witnesses for all inputs. async fn create_signed_shield_transition( signer: &TestAddressSigner, @@ -77,7 +78,7 @@ mod tests { fee_strategy: AddressFundsFeeStrategy, ) -> StateTransition { // First create with empty witnesses to compute signable bytes - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount, @@ -103,7 +104,7 @@ mod tests { } // Inject witnesses - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } st @@ -240,7 +241,7 @@ mod tests { .await; // Add an extra dummy witness to cause mismatch (1 input, 2 witnesses) - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { v0.input_witnesses.push(create_dummy_witness()); } @@ -306,7 +307,7 @@ mod tests { let actions: Vec = (0..17).map(|_| create_dummy_serialized_action()).collect(); - let transition = ShieldTransitionV0 { + let transition = ShieldTransitionV1 { inputs: BTreeMap::new(), actions, amount: 1000, @@ -571,7 +572,7 @@ mod tests { .await; // Tamper the witness signature - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { if let Some(AddressWitness::P2pkh { ref mut signature }) = v0.input_witnesses.first_mut() { @@ -634,7 +635,7 @@ mod tests { .await .expect("should sign"); - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { v0.input_witnesses = vec![wrong_witness]; } @@ -750,6 +751,12 @@ mod tests { mod proof_verification { use super::*; + use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::validation::state_transition::processor::process_state_transition; + use crate::platform_types::platform::PlatformRef; + use dpp::block::block_info::BlockInfo; + use dpp::shielded::compute_shielded_verification_fee; #[tokio::test] async fn test_invalid_proof_returns_shielded_proof_error() { @@ -779,9 +786,10 @@ mod tests { // at the actual proof verification step. assert_matches!( processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] ); } @@ -843,7 +851,7 @@ mod tests { (1 as AddressNonce, shield_amount + dash_to_credits!(0.01)), ); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -867,7 +875,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -886,6 +894,51 @@ mod tests { ); } + { + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let event = process_state_transition( + &platform_ref, + &BlockInfo::default(), + st.clone(), + Some(&transaction), + ) + .expect("prepare successful shield") + .data + .expect("successful shield event"); + let ExecutionEvent::PaidFromAddressInputs { + execution_operations, + additional_fixed_fee_cost, + .. + } = event + else { + panic!("address-paid shield event"); + }; + let StateTransition::Shield(ShieldTransition::V1(shield)) = &st else { + panic!("shield"); + }; + assert_eq!( + additional_fixed_fee_cost, + Some( + compute_shielded_verification_fee(shield.actions.len(), platform_version) + .expect("flat shield compute fee") + ) + ); + assert!( + execution_operations.iter().all(|operation| !matches!( + operation, + ValidationOperation::PrecalculatedOperation(_) + )), + "successful shields cover validation reads in their flat compute fee" + ); + } + let processing_result = process_transition(&platform, st, platform_version); assert_matches!( @@ -960,7 +1013,7 @@ mod tests { (1 as AddressNonce, dash_to_credits!(0.5)), ); - let transition = ShieldTransitionV0 { + let transition = ShieldTransitionV1 { inputs, actions: vec![create_dummy_serialized_action()], amount: 1000, @@ -1046,7 +1099,7 @@ mod tests { ), ); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: mutated_amount, // MUTATED @@ -1070,7 +1123,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -1080,9 +1133,10 @@ mod tests { // 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(_)) - )] + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] ); } @@ -1153,7 +1207,7 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(input_address, (1 as AddressNonce, requested_input_amount)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: forged_shield_amount, @@ -1177,7 +1231,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -1269,7 +1323,7 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(input_address, (1 as AddressNonce, input_amount)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -1293,7 +1347,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -1414,10 +1468,12 @@ mod tests { process_transition_and_commit, setup_platform_at_protocol_version, shielded_transfer_errors_revealing, OutputsOnlyBundle, }; + use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::block::block_info::BlockInfo; + use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; use dpp::validation::ConsensusValidationResult; use dpp::version::DefaultForPlatformVersion; use drive::state_transition_action::StateTransitionAction; @@ -1460,37 +1516,25 @@ mod tests { address: PlatformAddress, nonce: AddressNonce, bundle: &OutputsOnlyBundle, + platform_version: &PlatformVersion, ) -> StateTransition { let mut inputs = BTreeMap::new(); inputs.insert(address, (nonce, bundle.amount + dash_to_credits!(0.01))); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { - inputs: inputs.clone(), - actions: bundle.actions.clone(), - amount: bundle.amount, - anchor: bundle.anchor, - proof: bundle.proof.clone(), - binding_signature: bundle.binding_signature, - 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 mut witnesses: Vec = Vec::with_capacity(inputs.len()); - for input in inputs.keys() { - witnesses.push( - signer - .sign_create_witness(input, &signable_bytes) - .await - .expect("should sign"), - ); - } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { - v0.input_witnesses = witnesses; - } - st + ShieldTransition::try_from_bundle_with_signer( + inputs, + bundle.actions.clone(), + bundle.amount, + bundle.anchor, + bundle.proof.clone(), + bundle.binding_signature, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + signer, + 0, + platform_version, + ) + .await + .expect("signed Shield in the active wire format") } /// A funded address on `platform` and the signer for it. @@ -1516,7 +1560,7 @@ mod tests { let requested = dash_to_credits!(0.1); let mut inputs = BTreeMap::new(); inputs.insert(address, (1 as AddressNonce, requested)); - let transition = ShieldTransition::V0(ShieldTransitionV0 { + let fields = ShieldTransitionV0 { inputs, actions: nullifiers .iter() @@ -1534,7 +1578,27 @@ mod tests { ]), user_fee_increase: 0, input_witnesses: vec![], - }); + }; + let transition = match platform_version + .dpp + .state_transition_serialization_versions + .shield_state_transition + .default_current_version + { + 0 => ShieldTransition::V0(fields), + 1 => ShieldTransition::V1(ShieldTransitionV1 { + inputs: fields.inputs, + actions: fields.actions, + amount: fields.amount, + anchor: fields.anchor, + proof: fields.proof, + binding_signature: fields.binding_signature, + fee_strategy: fields.fee_strategy, + user_fee_increase: fields.user_fee_increase, + input_witnesses: fields.input_witnesses, + }), + version => panic!("unsupported Shield format {version}"), + }; // The address held exactly `requested`, all of it debited by the balance check. let mut remaining = BTreeMap::new(); remaining.insert(address, (1 as AddressNonce, 0)); @@ -1554,6 +1618,7 @@ mod tests { &platform_ref, remaining, &BlockInfo::default(), + ValidationMode::NoValidation, &mut execution_context, None, ) @@ -1567,7 +1632,7 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, platform_version); - let st = signed_shield(&signer, address, 1, &bundle).await; + let st = signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, st, platform_version); assert_matches!( @@ -1589,7 +1654,8 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, platform_version); - let first = signed_shield(&signer, address, 1, &bundle).await; + let first = + signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, first, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1600,7 +1666,8 @@ mod tests { // nullifiers. The binding does not stop this — it covers the funding addresses, not // the transition carrying them — so the repeat travels the whole block path and the // recorded nullifier is what refuses it. - let repeat = signed_shield(&signer, address, 2, &bundle).await; + let repeat = + signed_shield(&signer, address, 2, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, repeat, platform_version); let first_nullifier = bundle.nullifiers()[0]; @@ -1635,8 +1702,10 @@ mod tests { let mut platform = setup_platform(); let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, pv); - let first = signed_shield(&signer, address, 1, &bundle).await; - let repeat = signed_shield(&signer, address, 2, &bundle).await; + let first = + signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; + let repeat = + signed_shield(&signer, address, 2, &bundle, PlatformVersion::latest()).await; let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); let result = platform @@ -1690,7 +1759,7 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, platform_version); - let st = signed_shield(&signer, address, 1, &bundle).await; + let st = signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, st, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1716,7 +1785,7 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = unbound_bundle(); - let first = signed_shield(&signer, address, 1, bundle).await; + let first = signed_shield(&signer, address, 1, bundle, platform_version).await; let result = process_transition_and_commit(&platform, first, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1729,7 +1798,7 @@ mod tests { ); } - let repeat = signed_shield(&signer, address, 2, bundle).await; + let repeat = signed_shield(&signer, address, 2, bundle, platform_version).await; let result = process_transition_and_commit(&platform, repeat, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1870,7 +1939,7 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(input_address, (1 as AddressNonce, requested)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -1894,7 +1963,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -2049,7 +2118,7 @@ mod tests { inputs.insert(addr_a, (1 as AddressNonce, requested)); inputs.insert(addr_b, (1 as AddressNonce, requested)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -2072,7 +2141,7 @@ mod tests { .expect("should sign"); witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -2120,6 +2189,7 @@ mod tests { mod mainnet_halt_repro { use super::*; use crate::execution::validation::state_transition::state_transitions::test_helpers::insert_dummy_encrypted_notes; + use crate::execution::validation::state_transition::state_transitions::test_helpers::setup_platform_at_protocol_version; use dpp::block::block_info::BlockInfo; /// Note count on mainnet's shielded commitment tree around the halt. @@ -2193,43 +2263,28 @@ mod tests { signer: &TestAddressSigner, addr: PlatformAddress, declared_input: u64, + pv: &PlatformVersion, ) -> StateTransition { - let mut inputs = BTreeMap::new(); - inputs.insert(addr, (1 as AddressNonce, declared_input)); - - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { - inputs: inputs.clone(), - actions: b.actions.clone(), - amount: b.shield_amount, - anchor: b.anchor, - proof: b.proof.clone(), - binding_signature: b.binding_sig, - fee_strategy: AddressFundsFeeStrategy::from(vec![ - AddressFundsFeeStrategyStep::DeductFromInput(0), - ]), - user_fee_increase: 0, - input_witnesses: vec![], - })); - let signable = st.signable_bytes().expect("should compute signable bytes"); - let mut witnesses: Vec = Vec::with_capacity(inputs.len()); - for a in inputs.keys() { - witnesses.push( - signer - .sign_create_witness(a, &signable) - .await - .expect("sign"), - ); - } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { - v0.input_witnesses = witnesses; - } - st + ShieldTransition::try_from_bundle_with_signer( + BTreeMap::from([(addr, (1, declared_input))]), + b.actions.clone(), + b.shield_amount, + b.anchor, + b.proof.clone(), + b.binding_sig, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + signer, + 0, + pv, + ) + .await + .expect("signed Shield") } /// Run the shield on a fresh platform whose input address holds exactly /// `shield_amount + headroom`, i.e. `headroom` credits are available to pay the fee. async fn run_at(headroom: u64, b: &Bundle, pv: &PlatformVersion) -> (Outcome, String) { - let mut platform = setup_platform(); + let mut platform = setup_platform_at_protocol_version(pv.protocol_version); insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); let mut signer = TestAddressSigner::new(); @@ -2238,7 +2293,7 @@ mod tests { let declared_input = b.shield_amount + headroom; setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - let st = build_signed(b, &signer, addr, declared_input).await; + let st = build_signed(b, &signer, addr, declared_input, pv).await; let result = process_transition(&platform, st, pv); match result.execution_results().first() { Some(StateTransitionExecutionResult::SuccessfulExecution { .. }) => { @@ -2357,10 +2412,7 @@ mod tests { #[tokio::test] async fn dropped_shield_must_not_mutate_state() { let pv = PlatformVersion::get(13).expect("protocol version 13 should exist"); - // `setup_platform` starts at the latest protocol version, and - // `process_state_transition` validates at the platform state's version, not at `pv`, - // so the bundle is bound the way the latest version's proof check rebuilds it. - let b = build_bundle(PlatformVersion::latest()); + let b = build_bundle(pv); // Least headroom validation lets through to execution. const CEILING: u64 = 5_000_000_000; @@ -2409,7 +2461,7 @@ mod tests { // Mid-band: accepted by validation, rejected by execution. let headroom = accepted + (executes - accepted) / 2; - let mut platform = setup_platform(); + let mut platform = setup_platform_at_protocol_version(pv.protocol_version); insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); let mut signer = TestAddressSigner::new(); let addr = signer.add_p2pkh([1u8; 32]); @@ -2432,7 +2484,7 @@ mod tests { .unwrap() .expect("root hash"); - let st = build_signed(&b, &signer, addr, declared_input).await; + let st = build_signed(&b, &signer, addr, declared_input, pv).await; let bytes = st.serialize_to_bytes().expect("serialize"); let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); @@ -2525,7 +2577,7 @@ mod tests { let declared_input = b.shield_amount + headroom; setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - let st = build_signed(&b, &signer, addr, declared_input).await; + let st = build_signed(&b, &signer, addr, declared_input, pv).await; let bytes = st.serialize_to_bytes().expect("serialize"); let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); @@ -2700,7 +2752,7 @@ mod tests { let declared_input = b.shield_amount + headroom; setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - let st = build_signed(&b, &signer, addr, declared_input).await; + let st = build_signed(&b, &signer, addr, declared_input, pv).await; let bytes = st.serialize_to_bytes().expect("serialize"); let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); @@ -2905,7 +2957,7 @@ mod tests { binding_signature: [u8; 64], ) -> StateTransition { let mut transition = - StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount, @@ -2928,7 +2980,7 @@ mod tests { .expect("should sign"), ); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { v0.input_witnesses = witnesses; } transition @@ -2962,14 +3014,23 @@ mod tests { )], "CheckTx must refuse it for its proof" ); - assert_matches!( - process_transition(platform, transition.clone(), platform_version) - .execution_results() - .as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] - ); + let result = process_transition(platform, transition.clone(), platform_version); + if platform_version.protocol_version < 14 { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } else { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } } /// Somebody funds a `Shield` from their own addresses and wraps it around a bundle proved @@ -2988,7 +3049,7 @@ mod tests { // Positive control: the builder's bundle is what both CheckTx and the block accept. assert_admitted_and_executed(&platform, &shield, platform_version); - let StateTransition::Shield(ShieldTransition::V0(proven)) = &shield else { + let StateTransition::Shield(ShieldTransition::V1(proven)) = &shield else { panic!("expected a shield transition"); }; let copy = wrap( @@ -3085,9 +3146,904 @@ mod tests { let shield = builder_made_shield(inputs.clone(), &signer, platform_version).await; assert_admitted_and_executed(&platform, &shield, platform_version); - let bound = builder_made_shield(inputs, &signer, PlatformVersion::latest()).await; - assert_refused_for_its_proof(&platform, &bound, platform_version); + let bound = + builder_made_shield(inputs.clone(), &signer, PlatformVersion::latest()).await; + let StateTransition::Shield(ShieldTransition::V1(bound)) = bound else { + panic!("bound Shield"); + }; + let historical_wrapper = ShieldTransition::try_from_bundle_with_signer( + inputs, + bound.actions, + bound.amount, + bound.anchor, + bound.proof, + bound.binding_signature, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + platform_version, + ) + .await + .expect("historical-format wrapper signed over the bound bundle"); + assert_refused_for_its_proof(&platform, &historical_wrapper, platform_version); + } + } + } + mod legacy_format_activation { + use super::*; + use crate::execution::check_tx::CheckTxLevel; + use crate::execution::validation::state_transition::processor::process_state_transition; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + has_recorded_nullifier, setup_platform_at_protocol_version, test_orchard_recipient, + TestOrchardProver, + }; + use crate::platform_types::platform::PlatformRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::block::block_info::BlockInfo; + use dpp::shielded::builder::build_shield_transition; + use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; + + fn process_bytes_and_commit( + platform: &TempPlatform, + bytes: &[u8], + pv: &PlatformVersion, + ) -> Vec { + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes.to_vec()], + &state, + &BlockInfo::default(), + &transaction, + pv, + false, + None, + ) + .expect("process exact signed bytes"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit"); + result.execution_results().to_vec() + } + + #[tokio::test] + async fn should_refuse_a_pending_legacy_shield_without_charging_after_activation() { + let balance = dash_to_credits!(1.0); + let amount = 5_000; + for protocol_version in [13, 12] { + let historical = + PlatformVersion::get(protocol_version).expect("historical version"); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([114; 32]); + let transition = build_shield_transition( + &test_orchard_recipient(), + amount, + BTreeMap::from([(address, (1, balance))]), + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + &TestOrchardProver, + [0; 36], + None, + historical, + ) + .await + .expect("historical client-built Shield"); + let StateTransition::Shield(shield) = &transition else { + panic!("Shield"); + }; + let nullifiers: Vec<[u8; 32]> = shield.nullifiers(); + let bytes = transition.serialize_to_bytes().expect("serialize once"); + + let mut old_platform = setup_platform_at_protocol_version(protocol_version); + setup_address_with_balance(&mut old_platform, address, 0, balance); + assert_matches!( + process_bytes_and_commit(&old_platform, &bytes, historical).as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "the unchanged transaction must be valid before activation" + ); + assert_eq!( + old_platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], historical) + .expect("historical pool"), + amount + ); + + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + setup_address_with_balance(&mut platform, address, 0, balance); + { + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + for level in [CheckTxLevel::FirstTimeCheck, CheckTxLevel::Recheck] { + let admission = platform + .check_tx(&bytes, level, &platform_ref, pv) + .expect("inactive format is a coded refusal"); + assert_matches!( + admission.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.state_transition_type() == "Shield" + && error.current_protocol_version() == pv.protocol_version + && error.required_protocol_version() == 13, + "raw CheckTx and Recheck must evict the legacy format unpaid" + ); + assert!(admission + .data + .expect("admission result") + .fee_result + .is_none()); + } + + let mut decoded = transition.clone(); + let StateTransition::Shield(ShieldTransition::V0(v0)) = &mut decoded else { + panic!("historical format"); + }; + v0.input_witnesses.clear(); + let direct = process_state_transition( + &platform_ref, + &BlockInfo::default(), + decoded, + None, + ) + .expect("already-decoded legacy format is refused"); + assert!(direct.data.is_none(), "no fee-paying action is constructed"); + assert_matches!( + direct.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.required_protocol_version() == 13, + "format refusal precedes authentication even for direct callers" + ); + } + let results = process_bytes_and_commit(&platform, &bytes, pv); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)), + "a pending historical Shield must not be charged after activation" + ); + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error)) + )] if error.required_protocol_version() == 13 + ); + + let StateTransition::Shield(ShieldTransition::V0(v0)) = transition.clone() else { + panic!("historical format"); + }; + let retagged = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { + inputs: v0.inputs, + actions: v0.actions, + amount: v0.amount, + anchor: v0.anchor, + proof: v0.proof, + binding_signature: v0.binding_signature, + fee_strategy: v0.fee_strategy, + user_fee_increase: v0.user_fee_increase, + input_witnesses: v0.input_witnesses, + })); + let retagged_bytes = retagged + .serialize_to_bytes() + .expect("retag without resigning"); + assert_eq!(retagged_bytes.len(), bytes.len()); + assert_eq!( + retagged_bytes + .iter() + .zip(&bytes) + .filter(|(a, b)| a != b) + .count(), + 1, + "only the signed format tag was changed" + ); + assert_matches!( + process_bytes_and_commit(&platform, &retagged_bytes, pv).as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(_) + )], + "a version tag cannot upgrade old address authorization" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)), + "invalid witnesses cannot authorize a fee or consume a nonce" + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes"), + 0 + ); + for nullifier in nullifiers { + assert!(!has_recorded_nullifier(&platform, &nullifier)); + } + assert_eq!( + transition.serialize_to_bytes().expect("unchanged bytes"), + bytes + ); + } + } + } + + mod paid_proof_failures { + use super::*; + use crate::execution::check_tx::CheckTxLevel; + use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::validation::state_transition::processor::process_state_transition; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + check_tx_errors, has_recorded_nullifier, insert_nullifier_into_state, + process_transition_and_commit, setup_platform_at_protocol_version, + }; + use crate::platform_types::platform::PlatformRef; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::block::block_info::BlockInfo; + use drive::drive::Drive; + + #[tokio::test] + async fn should_charge_a_failed_shield_proof_and_consume_its_nonce() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([91; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = create_default_signed_shield_transition(&signer, address, 1, balance).await; + let result = process_transition_and_commit(&platform, st, pv); + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("authenticated bad proof must be paid, got {other:?}"), + }; + let penalty = pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + assert!(actual >= penalty, "a funded proof failure pays the penalty"); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((1, balance - actual)) + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes"), + 0 + ); + assert!(!has_recorded_nullifier( + &platform, + &create_dummy_serialized_action().nullifier + )); + } + fn failure_fee_and_penalty( + platform: &TempPlatform, + st: &StateTransition, + ) -> (Credits, Credits) { + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let pv = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let StateTransition::Shield(ShieldTransition::V1(shield)) = st else { + panic!("shield"); + }; + let mut read_operations = vec![]; + platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut read_operations, pv) + .expect("pool read"); + for action in &shield.actions { + assert!(!platform + .drive + .has_nullifier( + &action.nullifier, + Some(&transaction), + &mut read_operations, + pv, + ) + .expect("nullifier read")); + } + let read_fee = Drive::calculate_fee( + None, + Some(read_operations), + &block_info.epoch, + platform.drive.config.epochs_per_era, + pv, + None, + ) + .expect("independently price validation reads"); + assert!(read_fee.processing_fee > 0); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let mut event = process_state_transition( + &platform_ref, + &BlockInfo::default(), + st.clone(), + Some(&transaction), + ) + .expect("prepare failure") + .data + .expect("failure event"); + let ExecutionEvent::PaidFromAddressInputs { + ref mut additional_fixed_fee_cost, + ref operations, + ref execution_operations, + user_fee_increase, + .. + } = event + else { + panic!("address-paid failure event"); + }; + let penalty = additional_fixed_fee_cost.replace(0).expect("penalty"); + assert_eq!( + execution_operations + .iter() + .filter(|operation| matches!( + operation, + ValidationOperation::PrecalculatedOperation(fee) if fee == &read_fee + )) + .count(), + 1, + "a failed proof must retain its pool and nullifier read costs exactly once" + ); + let authentication_operations = execution_operations + .iter() + .filter(|operation| { + !matches!( + operation, + ValidationOperation::PrecalculatedOperation(fee) if fee == &read_fee + ) + }) + .cloned() + .collect::>(); + let mut without_reads = platform + .drive + .apply_drive_operations( + operations.clone(), + false, + &block_info, + Some(&transaction), + pv, + Some(state.previous_fee_versions()), + ) + .expect("price nonce updates"); + ValidationOperation::add_many_to_fee_result( + &authentication_operations, + &mut without_reads, + pv, + ) + .expect("price authentication"); + let mut complete = without_reads.clone(); + complete + .checked_add_assign(read_fee) + .expect("include independently measured reads"); + complete.apply_user_fee_increase(user_fee_increase); + without_reads.apply_user_fee_increase(user_fee_increase); + assert!( + without_reads.total_base_fee() < complete.total_base_fee(), + "one credit below the complete fee still covers the old incomplete estimate" + ); + let fee = platform + .platform + .validate_fees_of_event( + &event, + &BlockInfo::default(), + Some(&transaction), + PlatformVersion::latest(), + state.previous_fee_versions(), + ) + .expect("estimate failure fee") + .data + .expect("estimate") + .total_base_fee(); + assert_eq!(fee, complete.total_base_fee()); + (fee, penalty) + } + + async fn set_increase_and_resign( + st: &mut StateTransition, + signer: &TestAddressSigner, + increase: u16, + ) { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { + panic!("shield"); + }; + v0.user_fee_increase = increase; + let addresses: Vec<_> = v0.inputs.keys().copied().collect(); + let bytes = st.signable_bytes().expect("signable"); + let mut witnesses = vec![]; + for address in addresses { + witnesses.push( + signer + .sign_create_witness(&address, &bytes) + .await + .expect("witness"), + ); + } + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { + panic!("shield"); + }; + v0.input_witnesses = witnesses; + } + + #[tokio::test] + async fn should_split_failed_proof_fees_across_signed_payers_in_strategy_order() { + let pv = PlatformVersion::latest(); + let nominal = pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + let balance = nominal * 3 / 4; + + for indices in [[1, 0], [0, 1]] { + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address_a = signer.add_p2pkh([98; 32]); + let address_b = signer.add_p2pkh([99; 32]); + for address in [address_a, address_b] { + setup_address_with_balance(&mut platform, address, 0, balance); + } + let inputs = BTreeMap::from([(address_a, (1, balance)), (address_b, (1, balance))]); + let addresses: Vec<_> = inputs.keys().copied().collect(); + let first_payer = addresses[indices[0] as usize]; + let second_payer = addresses[indices[1] as usize]; + let action = create_dummy_serialized_action(); + let nullifier = action.nullifier; + let st = create_signed_shield_transition( + &signer, + inputs, + vec![action], + 1_000, + vec![0; 100], + [0; 64], + AddressFundsFeeStrategy::from( + indices + .into_iter() + .map(AddressFundsFeeStrategyStep::DeductFromInput) + .collect::>(), + ), + ) + .await; + + let (base, penalty) = failure_fee_and_penalty(&platform, &st); + assert_eq!(penalty, nominal, "both signed payers fund the full penalty"); + assert!(base + penalty <= balance * 2); + assert!( + balance < base + penalty, + "neither payer covers the estimate alone" + ); + + let result = process_transition_and_commit(&platform, st, pv); + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("jointly funded failure must be paid: {other:?}"), + }; + assert!(actual >= penalty, "the prepared penalty is charged"); + assert!(actual <= base + penalty, "the estimate covers execution"); + assert!( + actual > balance, + "neither payer covers the actual charge alone" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&first_payer, None, pv) + .expect("first payer"), + Some((1, 0)), + "the first signed payer is exhausted and retains its consumed nonce" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&second_payer, None, pv) + .expect("second payer"), + Some((1, balance - (actual - balance))), + "the second signed payer covers only the remaining fee" + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes"), + 0 + ); + assert!(!has_recorded_nullifier(&platform, &nullifier)); + } + } + + #[tokio::test] + async fn should_reserve_validation_reads_before_capping_failed_proof_penalties() { + let pv = PlatformVersion::latest(); + let nominal = pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + for increase in [0, 100, u16::MAX] { + let mut funded = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([92; 32]); + let nonpayer = signer.add_p2pkh([96; 32]); + setup_address_with_balance(&mut funded, nonpayer, 0, dash_to_credits!(1.0)); + setup_address_with_balance(&mut funded, address, 0, dash_to_credits!(1.0)); + let inputs = BTreeMap::from([ + ( + address, + (1, pv.dpp.state_transitions.address_funds.min_input_amount), + ), + (nonpayer, (1, dash_to_credits!(1.0))), + ]); + let payer_index = inputs + .keys() + .position(|input| *input == address) + .expect("payer") as u16; + let mut st = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1_000, + vec![0; 100], + [0; 64], + AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(payer_index), + ]), + ) + .await; + set_increase_and_resign(&mut st, &signer, increase).await; + let (mut base, _) = failure_fee_and_penalty(&funded, &st); + // Storage estimation includes the sum item's balance encoding. Find + // the fee with an affordable balance in the boundary's own width. + for _ in 0..4 { + setup_address_with_balance(&mut funded, address, 0, base + nominal + 1); + let (next_base, _) = failure_fee_and_penalty(&funded, &st); + if next_base == base { + break; + } + base = next_base; + } + assert_eq!(failure_fee_and_penalty(&funded, &st).0, base); + let mut actual_without_penalty = None; + for balance in [base - 1, base, base + 1, base + nominal - 1, base + nominal] { + let mut platform = setup_platform(); + setup_address_with_balance(&mut platform, address, 0, balance); + setup_address_with_balance(&mut platform, nonpayer, 0, dash_to_credits!(1.0)); + let penalty = nominal.min(balance.saturating_sub(base)); + if balance >= base { + let (estimated_base, prepared_penalty) = + failure_fee_and_penalty(&platform, &st); + assert_eq!(estimated_base, base); + assert_eq!(prepared_penalty, penalty); + } + let result = process_transition_and_commit(&platform, st.clone(), pv); + if balance < base { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError( + StateError::AddressesNotEnoughFundsError(_) + ) + )] + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)) + ); + } else { + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("affordable failure must be paid: {other:?}"), + }; + assert!(actual <= base + penalty, "estimate must cover the charge"); + let metered_base = *actual_without_penalty.get_or_insert(actual); + assert_eq!( + actual, + metered_base + penalty, + "the fixed penalty is included exactly once and is not increased" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((1, balance - actual)) + ); + } + let expected_nonce = if balance < base { 0 } else { 1 }; + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&nonpayer, None, pv) + .expect("nonpayer"), + Some((expected_nonce, dash_to_credits!(1.0))), + "nonpayer pays no fee or principal" + ); + assert!(!has_recorded_nullifier( + &platform, + &create_dummy_serialized_action().nullifier + )); + } + } + } + + #[tokio::test] + async fn should_leave_duplicate_nullifiers_unpaid_even_with_a_bad_proof() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([97; 32]); + let initial = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, initial); + let nullifier = create_dummy_serialized_action().nullifier; + insert_nullifier_into_state(&platform, &nullifier); + let st = create_default_signed_shield_transition(&signer, address, 1, initial).await; + let result = process_transition_and_commit(&platform, st, pv); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::NullifierAlreadySpentError(error)) + )] if error.nullifier() == nullifier + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, initial)) + ); + } + + #[tokio::test] + async fn should_cover_failed_proof_fees_at_the_input_and_action_limits() { + let pv = PlatformVersion::latest(); + let initial = dash_to_credits!(1.0); + for action_count in [1, pv.system_limits.max_shielded_transition_actions as usize] { + for increase in [0, u16::MAX] { + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let mut inputs = BTreeMap::new(); + for input in 0..pv.dpp.state_transitions.max_address_inputs { + let address = signer.add_p2pkh([input as u8 + 100; 32]); + setup_address_with_balance(&mut platform, address, 0, initial); + inputs.insert(address, (1, initial)); + } + let mut st = create_signed_shield_transition( + &signer, + inputs.clone(), + (0..action_count) + .map(|i| SerializedAction { + nullifier: [i as u8; 32], + ..create_dummy_serialized_action() + }) + .collect(), + 1000, + vec![0; 100], + [0; 64], + AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + ) + .await; + set_increase_and_resign(&mut st, &signer, increase).await; + let (base, penalty) = failure_fee_and_penalty(&platform, &st); + let result = process_transition_and_commit(&platform, st, pv); + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("funded failure at the limits must pay: {other:?}"), + }; + assert!(actual <= base + penalty, "the estimate covers execution"); + for (index, address) in inputs.keys().enumerate() { + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(address, None, pv) + .expect("address"), + Some((1, initial - if index == 0 { actual } else { 0 })), + "only the signed payer funds the failure" + ); + } + } } } + + #[tokio::test] + async fn should_leave_shipped_protocol_proof_failures_unpaid() { + for version in [12, 13] { + let pv = PlatformVersion::get(version).expect("shipped version"); + let mut platform = setup_platform_at_protocol_version(version); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([93; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = ShieldTransition::try_from_bundle_with_signer( + BTreeMap::from([(address, (1, balance))]), + vec![create_dummy_serialized_action()], + 1000, + [42; 32], + vec![0; 100], + [0; 64], + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + pv, + ) + .await + .expect("historical signed Shield"); + let result = process_transition_and_commit(&platform, st, pv); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)) + ); + } + } + + #[tokio::test] + async fn should_reject_failed_proofs_at_check_tx_without_consuming_a_nonce() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([94; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = create_default_signed_shield_transition(&signer, address, 1, balance).await; + assert_matches!( + check_tx_errors(&platform, &st).as_slice(), + [ConsensusError::StateError( + StateError::InvalidShieldedProofError(_) + )] + ); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let recheck = platform + .check_tx( + &st.serialize_to_bytes().expect("serialize"), + CheckTxLevel::Recheck, + &platform_ref, + pv, + ) + .expect("recheck performs no proof work"); + assert!(recheck.is_valid()); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)) + ); + } + + #[tokio::test] + async fn should_charge_a_bad_proof_once_then_reject_its_same_block_replay() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([95; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = create_default_signed_shield_transition(&signer, address, 1, balance).await; + let bytes = st.serialize_to_bytes().expect("serialize"); + let state = platform.state.load(); + let tx = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &[bytes.clone(), bytes], + &state, + &BlockInfo::default(), + &tx, + pv, + false, + None, + ) + .expect("block processing"); + assert_matches!( + result.execution_results().as_slice(), + [ + StateTransitionExecutionResult::PaidConsensusError { .. }, + StateTransitionExecutionResult::UnpaidConsensusError(_) + ] + ); + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .expect("commit"); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address") + .expect("exists") + .0, + 1 + ); + assert!(!has_recorded_nullifier( + &platform, + &create_dummy_serialized_action().nullifier + )); + } } } 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 index 008be12cc67..a013a2f7357 100644 --- 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 @@ -1,2 +1,3 @@ pub(crate) mod v0; pub(crate) mod v1; +pub(super) mod v2; 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 index 15322829922..0e9cb7b7938 100644 --- 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 @@ -2,12 +2,13 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; -use dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; +use dpp::address_funds::{AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, PlatformAddress}; use dpp::block::block_info::BlockInfo; use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; -use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; @@ -21,6 +22,9 @@ use std::collections::{BTreeMap, BTreeSet}; /// /// # Why this is needed /// +/// The input map and fee strategy are passed directly, independently of the wire +/// format. Historical V0 callers supply the same fields and keep the same allocation. +/// /// Each `ShieldTransitionV0::inputs` entry maps an address to `(nonce, requested)`, /// where `requested` is a **max contribution**, not an exact debit (see the field /// doc on `ShieldTransitionV0::inputs`: "The total across all inputs must cover @@ -75,13 +79,11 @@ use std::collections::{BTreeMap, BTreeSet}; /// invariant — it would otherwise mint credits (pool credited `shield_amount`, addresses debited /// less). pub(super) fn reallocate_inputs_for_shield_amount( - transition: &ShieldTransitionV0, + requested_inputs: &BTreeMap, + fee_strategy: &AddressFundsFeeStrategy, inputs_with_remaining_balance: BTreeMap, shield_amount: Credits, ) -> Result, Error> { - let requested_inputs = &transition.inputs; - let fee_strategy = &transition.fee_strategy; - // Identify which input addresses the fee strategy deducts the fee from. A // `DeductFromInput(index)` step refers to the address at that position in the // input set's `BTreeMap` (address) order — the SAME order the fee machinery @@ -201,8 +203,7 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { _execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { - let ShieldTransition::V0(transition_v0) = self; - let shield_amount: Credits = transition_v0.amount; + let shield_amount: Credits = self.amount(); // The shared address-balance validation debits the FULL per-input `requested` // (a max contribution), but the shielded pool only receives `shield_amount`. @@ -210,7 +211,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { // leaving the excess in the source addresses. This keeps credits conserved // (addresses lose `shield_amount` + fee, pool gains `shield_amount`). let inputs_with_remaining_balance = reallocate_inputs_for_shield_amount( - transition_v0, + self.inputs(), + self.fee_strategy(), inputs_with_remaining_balance, shield_amount, )?; @@ -241,7 +243,9 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { mod tests { use super::*; use dpp::address_funds::PlatformAddress; + use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::StateTransitionWitnessSigned; use std::collections::BTreeMap; /// Builds a minimal `ShieldTransitionV0` whose `inputs` map carries the per-input @@ -282,8 +286,13 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (1u32, actual - requested)); - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .unwrap(); let (nonce, new_remaining) = adjusted.get(&addr).copied().unwrap(); assert_eq!(nonce, 1u32, "nonce must be preserved"); @@ -352,8 +361,13 @@ mod tests { remaining.insert(addr_a, (1u32, actual_a - req_a)); // 0 remaining.insert(addr_b, (1u32, actual_b - req_b)); // 500_000 - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .unwrap(); let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); @@ -407,9 +421,13 @@ mod tests { remaining.insert(addr_b, (1u32, rem_b)); remaining.insert(addr_c, (1u32, rem_c)); - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining.clone(), shield_amount) - .unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining.clone(), + shield_amount, + ) + .unwrap(); // The SUM of (actual - adjusted) across inputs == shield_amount exactly. let actuals = [(addr_a, actual_a), (addr_b, actual_b), (addr_c, actual_c)]; @@ -473,8 +491,13 @@ mod tests { remaining.insert(addr_a, (1u32, actual_a - req_a)); remaining.insert(addr_b, (1u32, actual_b - req_b)); - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .unwrap(); let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); @@ -497,7 +520,12 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (1u32, 5000)); - let result = reallocate_inputs_for_shield_amount(&transition, remaining, 1000); + let result = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + 1000, + ); assert!(result.is_err(), "missing transition input must error"); } @@ -519,8 +547,13 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (1u32, 0)); // actual - requested - let err = reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount) - .expect_err("shield_amount > Σrequested must be rejected, not minted"); + let err = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .expect_err("shield_amount > Σrequested must be rejected, not minted"); match err { Error::Execution(ExecutionError::CorruptedCodeExecution(msg)) => assert!( msg.contains("exceeds the sum of input contributions"), @@ -546,6 +579,11 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (2u32, 0)); // remaining-balance nonce = 2 (diverges) - let _ = reallocate_inputs_for_shield_amount(&transition, remaining, 500); + let _ = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + 500, + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs index 596eedfa96a..7f42713ab45 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs @@ -8,7 +8,9 @@ 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::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; @@ -48,8 +50,7 @@ impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { _execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { - let ShieldTransition::V0(transition_v0) = self; - let shield_amount: Credits = transition_v0.amount; + let shield_amount: Credits = self.amount(); // The shared address-balance validation debits the FULL per-input `requested` // (a max contribution), but the shielded pool only receives `shield_amount`. @@ -57,7 +58,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { // leaving the excess in the source addresses. This keeps credits conserved // (addresses lose `shield_amount` + fee, pool gains `shield_amount`). let inputs_with_remaining_balance = reallocate_inputs_for_shield_amount( - transition_v0, + self.inputs(), + self.fee_strategy(), inputs_with_remaining_balance, shield_amount, )?; @@ -75,8 +77,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; // Validate nullifiers: intra-bundle duplicates + already recorded in state - let nullifiers: Vec<[u8; 32]> = transition_v0 - .actions + let nullifiers: Vec<[u8; 32]> = self + .actions() .iter() .map(|action| action.nullifier) .collect(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs new file mode 100644 index 00000000000..63253baf2c4 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs @@ -0,0 +1,214 @@ +use super::v0::reallocate_inputs_for_shield_amount; +use crate::error::execution::ExecutionError; +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, reconstruct_and_verify_bundle, validate_nullifiers, FLAGS_OUTPUTS_ONLY, +}; +use crate::execution::validation::state_transition::ValidationMode; +use dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::Epoch; +use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; +use dpp::consensus::state::address_funds::AddressesNotEnoughFundsError; +use dpp::consensus::state::state_error::StateError; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::Credits; +use dpp::prelude::{AddressNonce, ConsensusValidationResult}; +use dpp::shielded::shield_extra_sighash_data; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use drive::state_transition_action::address_funds::restore_input_spends_for_failed_transition; +use drive::state_transition_action::shielded::shield::ShieldTransitionAction; +use drive::state_transition_action::system::bump_address_input_nonces_action::{ + BumpAddressInputNoncesAction, BumpAddressInputNoncesActionV0, +}; +use drive::state_transition_action::StateTransitionAction; +use std::collections::{BTreeMap, BTreeSet}; + +pub(in crate::execution::validation::state_transition::state_transitions::shield) trait ShieldStateTransitionTransformIntoActionValidationV2 +{ + #[allow(clippy::too_many_arguments)] + fn transform_into_action_v2( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + validation_mode: ValidationMode, + epoch: &Epoch, + previous_fee_versions: &CachedEpochIndexFeeVersions, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldStateTransitionTransformIntoActionValidationV2 for ShieldTransition { + /// Version 1's nullifier checks and successful shielding, with proof verification + /// performed here during block validation so an authenticated bad proof can return + /// a paid nonce-bump action. The input principal is restored, and the fixed penalty + /// is capped by signed fee-payer funds left after reserving the estimated base fee. + /// Insufficient base-fee funds and duplicate nullifiers still produce unpaid + /// refusals; nullifiers are checked before the proof. CheckTx keeps proof + /// verification in its caller, and rechecks do not verify it again. + #[allow(clippy::too_many_arguments)] + fn transform_into_action_v2( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + validation_mode: ValidationMode, + epoch: &Epoch, + previous_fee_versions: &CachedEpochIndexFeeVersions, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let ShieldTransition::V1(transition) = self else { + let legacy = StateTransition::Shield(self.clone()); + return Ok(ConsensusValidationResult::new_with_error( + StateTransitionNotActiveError::new( + legacy.name(), + platform_version.protocol_version, + *legacy.active_version_range().end(), + ) + .into(), + )); + }; + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + let nullifiers: Vec<_> = transition + .actions + .iter() + .map(|action| action.nullifier) + .collect(); + if let Some(refusal) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(refusal); + } + + // CheckTx's caller verifies once under its local proof permit. Recheck and + // action-only construction do no proof work and cannot prepare paid failures. + if validation_mode == ValidationMode::Validator { + let extra_sighash_data = + shield_extra_sighash_data(&transition.inputs, platform_version)?; + if let Err(error) = reconstruct_and_verify_bundle( + &transition.actions, + FLAGS_OUTPUTS_ONLY, + -(transition.amount as i64), + &transition.anchor, + &transition.proof, + &transition.binding_signature, + &extra_sighash_data, + ) { + // A nonce-only failure has no Shield compute fee covering these reads. + // Retain them as ordinary metered work before reserving the base fee. + let state_read_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(state_read_fee)); + + // These balances still reflect the full requested debits. Restore them + // before successful-path reallocation so a failure moves no principal. + let mut restored = inputs_with_remaining_balance; + restore_input_spends_for_failed_transition(&mut restored, &transition.inputs)?; + let mut bump = BumpAddressInputNoncesActionV0 { + inputs_with_remaining_balance: restored, + fee_strategy: transition.fee_strategy.clone(), + user_fee_increase: transition.user_fee_increase, + penalty_credits: 0, + }; + let operations = BumpAddressInputNoncesAction::from(bump.clone()) + .into_high_level_drive_operations(epoch, platform_version)?; + let mut estimated = drive.apply_drive_operations( + operations, + false, + block_info, + transaction, + platform_version, + Some(previous_fee_versions), + )?; + ValidationOperation::add_many_to_fee_result( + execution_context.operations_slice(), + &mut estimated, + platform_version, + )?; + estimated.apply_user_fee_increase(transition.user_fee_increase); + + let addresses: Vec<_> = bump.inputs_with_remaining_balance.values().collect(); + let mut paying_indices = BTreeSet::new(); + let mut available: Credits = 0; + for step in &transition.fee_strategy { + if let AddressFundsFeeStrategyStep::DeductFromInput(index) = step { + if paying_indices.insert(*index) { + if let Some((_, balance)) = addresses.get(*index as usize) { + available = available.checked_add(*balance).ok_or( + Error::Execution(ExecutionError::Overflow( + "shield failure fee-payer balance overflow", + )), + )?; + } + } + } + } + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + if available < estimated.total_base_fee() { + return Ok(ConsensusValidationResult::new_with_error( + AddressesNotEnoughFundsError::new( + bump.inputs_with_remaining_balance, + estimated.total_base_fee(), + ) + .into(), + )); + } + bump.penalty_credits = + penalty.min(available.saturating_sub(estimated.total_base_fee())); + + // Refuse before creating a paid-invalid event when its estimated fee + // cannot fit. Execution charges the fixed penalty once, without the + // user's fee increase. + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpAddressInputNoncesAction(bump.into()), + vec![StateError::InvalidShieldedProofError(error).into()], + )); + } + } + + let remaining = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + inputs_with_remaining_balance, + transition.amount, + )?; + Ok(ShieldTransitionAction::try_from_transition( + self, + remaining, + transition.amount, + current_total_balance, + ) + .map(|action| action.into())) + } +} 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 index 93e344e7d7a..68d156cb8e9 100644 --- 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 @@ -2717,7 +2717,7 @@ mod tests { [StateTransitionExecutionResult::SuccessfulExecution { .. }] ); - let StateTransition::Shield(ShieldTransition::V0(proven)) = &shield else { + let StateTransition::Shield(ShieldTransition::V1(proven)) = &shield else { panic!("expected a shield transition"); }; let mut rng = StdRng::seed_from_u64(42); 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 680ce0c40e1..9a16b210cf8 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 @@ -336,6 +336,7 @@ impl StateTransitionActionTransformer for StateTransition { platform, remaining_address_input_balances.clone(), block_info, + validation_mode, execution_context, tx, ) 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 4444a8c1e65..e53f56003e3 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 @@ -9,6 +9,7 @@ mod identity_and_document_tests; mod identity_transfer_tests; mod process_proposal_collision_tests; mod required_since_update_tests; +mod shield_paid_proof_failure_tests; // TODO: re-enable once OperationType shielded variants are implemented // mod shielded_tests; mod token_tests; diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs new file mode 100644 index 00000000000..a6397bbf682 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs @@ -0,0 +1,179 @@ +#[cfg(test)] +mod tests { + use crate::execution::run_chain_for_strategy; + use crate::strategy::NetworkStrategy; + use dpp::address_funds::{AddressFundsFeeStrategy, AddressFundsFeeStrategyStep}; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::Epoch; + use dpp::dash_to_credits; + use dpp::dashcore::hashes::Hash; + use dpp::identity::signer::Signer; + use dpp::serialization::Signable; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_transition::v1::ShieldTransitionV1; + use dpp::state_transition::shield_transition::ShieldTransition; + use dpp::state_transition::StateTransition; + use drive_abci::config::{ExecutionConfig, PlatformConfig, PlatformTestConfig}; + use drive_abci::mimic::MimicExecuteBlockOptions; + use drive_abci::platform_types::platform_state::PlatformStateV0Methods; + use drive_abci::test::helpers::setup::TestPlatformBuilder; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + use strategy_tests::frequency::Frequency; + use strategy_tests::operations::{Operation, OperationType}; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + + #[tokio::test] + async fn should_commit_a_paid_shield_proof_failure_after_independent_proposal_validation() { + let pv = PlatformVersion::latest(); + let config = PlatformConfig { + execution: ExecutionConfig { + verify_sum_trees: true, + ..Default::default() + }, + block_spacing_ms: 3000, + testing_configs: PlatformTestConfig::default_minimal_verifications(), + ..Default::default() + }; + let strategy = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(1.0)..=dash_to_credits!(1.0), + ), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo::default(), + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 50, + validator_quorum_count: 10, + chain_lock_quorum_count: 10, + verify_state_transition_results: false, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + let outcome = + run_chain_for_strategy(&mut platform, 5, strategy, config, 7, &mut None, &mut None) + .await; + let (&address, _) = outcome + .addresses_with_balance + .addresses_with_balance + .first_key_value() + .expect("funded address"); + let (nonce, balance) = outcome + .abci_app + .platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("funded address balance") + .expect("persisted funded address"); + let mut transition = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { + inputs: BTreeMap::from([(address, (nonce + 1, balance))]), + actions: vec![SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }], + amount: 1000, + anchor: [42; 32], + proof: vec![0; 100], + binding_signature: [0; 64], + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + let witness = outcome + .signer + .sign_create_witness(&address, &transition.signable_bytes().expect("signable")) + .await + .expect("witness"); + let StateTransition::Shield(ShieldTransition::V1(ref mut shield)) = transition else { + unreachable!(); + }; + shield.input_witnesses = vec![witness]; + let state = outcome.abci_app.platform.state.load(); + let block_info = BlockInfo { + height: state.last_committed_block_height() + 1, + core_height: state.last_committed_core_height(), + time_ms: outcome.end_time_ms + 3000, + epoch: Epoch::new(outcome.end_epoch_index).expect("epoch"), + }; + drop(state); + let block = outcome + .abci_app + .mimic_execute_block( + outcome.proposers[0].pro_tx_hash().to_byte_array(), + outcome.current_quorum(), + pv.protocol_version, + block_info, + 0, + &[40902], + false, + vec![transition], + MimicExecuteBlockOptions { + dont_finalize_block: false, + rounds_before_finalization: None, + max_tx_bytes_per_block: 40000, + independent_process_proposal_verification: true, + }, + ) + .expect("paid failure must survive preparation and independent validation"); + assert_eq!( + block.state_transaction_results.len(), + 1, + "failure stays in the block" + ); + let result = &block.state_transaction_results[0].1; + assert_eq!(result.code, 40902); + let (committed_nonce, committed_balance) = outcome + .abci_app + .platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address") + .expect("persisted address"); + assert_eq!(committed_nonce, nonce + 1); + assert!(committed_balance < balance); + assert!( + balance - committed_balance + >= pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure + ); + assert_eq!( + outcome + .abci_app + .platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + outcome + .abci_app + .platform + .state + .load() + .last_committed_block_height(), + block_info.height + ); + } +} diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs index ca619df9af9..9edd19daf82 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs @@ -1,5 +1,6 @@ use crate::state_transition_action::shielded::shield::v0::ShieldTransitionActionV0; use crate::state_transition_action::shielded::shield::ShieldTransitionAction; +use crate::state_transition_action::shielded::ShieldedActionNote; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; @@ -24,6 +25,17 @@ impl ShieldTransitionAction { ); result.map(|action| action.into()) } + ShieldTransition::V1(v1) => ConsensusValidationResult::new_with_data( + ShieldTransitionActionV0 { + inputs_with_remaining_balance, + shield_amount, + notes: v1.actions.iter().map(ShieldedActionNote::from).collect(), + fee_strategy: v1.fee_strategy.clone(), + user_fee_increase: v1.user_fee_increase, + current_total_balance, + } + .into(), + ), } } } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs index 4a437149a7c..2ad8740e170 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs @@ -169,9 +169,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V3: DPPStateTransitionSerializ default_current_version: 0, }, shield_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, + min_version: 1, + max_version: 1, + default_current_version: 1, }, shielded_transfer_state_transition: FeatureVersionBounds { min_version: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 74966d12af9..f4b46026b05 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -356,7 +356,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = identity_signatures: None, nonce: None, state: 0, - transform_into_action: 1, // changed: nullifier checks + transform_into_action: 2, }, shielded_transfer_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(0), @@ -449,7 +449,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = validate_address_witnesses: 0, validate_shielded_proof: 1, validate_minimum_shielded_fee: 0, - process_state_transition: 0, + process_state_transition: 1, state_transition_to_execution_event_for_check_tx: 0, penalties: PenaltyAmounts { identity_id_not_correct: 50000000, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a78074591e8..fedcc8ee6ef 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2019,6 +2019,17 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// tables of every earlier version select generation 0, the code it called /// before. /// +/// 81. **An authenticated Shield proof failure pays from its address inputs.** +/// Processor generation 1 defers Shield proof verification to transform generation 2. +/// A failed proof restores the input principal, consumes the input nonces, and charges +/// the ordinary metered failure fee plus the configured proof-failure penalty, capped +/// by the signed fee strategy's funds after reserving the estimated base fee. A failure +/// unable to cover the base fee stays unpaid. CheckTx still refuses invalid proofs +/// under its local proof permit; shipped protocol versions keep their unpaid refusal. +/// Shield wire format 1 signs the new authorization boundary. Format 0 remains valid +/// only at versions 12 and 13; after activation it is refused before authentication, +/// unpaid and without consuming input nonces, including when evicted by Recheck. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -2112,7 +2123,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { dpp: DPPVersion { costs: DPP_COSTS_VERSIONS_V1, validation: DPP_VALIDATION_VERSIONS_V5, // changed: validate_config_update 2 admits the contract moderation declaration of config V2 - state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire; ShieldFromAssetLock moves to version 1 alone; the ContractUserModeration transition + state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire; Shield and ShieldFromAssetLock move to version 1 alone; the ContractUserModeration transition state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, // changed: public keys in creation may carry a budget or an expiry; verify_identity_signed_signature 1: a BLS12_381 signature must verify state_transitions: STATE_TRANSITION_VERSIONS_V4, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index e7509b85389..0c217431adc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -264,12 +264,10 @@ pub(super) async fn queue_shielded_activity( /// Returns `&[]` for any non-shielded variant (none reach the recorder). /// Each shielded variant's `actions()` comes from its own accessor / /// methods trait, so the relevant traits are imported locally. -fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] { - // `actions()` is on each type's accessor trait; Shield exposes `actions` - // only as a public field on its V0 struct, so match down to that - // variant for it. +pub(super) fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] { + // Each accessor retains the actions across the supported wire formats. use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; - use dpp::state_transition::shield_transition::ShieldTransition; + use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shielded_transfer_transition::accessors::ShieldedTransferTransitionAccessorsV0; use dpp::state_transition::shielded_withdrawal_transition::accessors::ShieldedWithdrawalTransitionAccessorsV0; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; @@ -278,7 +276,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] use dpp::state_transition::unshield_transition::accessors::UnshieldTransitionAccessorsV0; match st { - StateTransition::Shield(ShieldTransition::V0(v0)) => &v0.actions, + StateTransition::Shield(t) => t.actions(), StateTransition::ShieldedTransfer(t) => t.actions(), StateTransition::Unshield(t) => t.actions(), StateTransition::ShieldFromAssetLock(t) => t.actions(), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs index 6cdb39536f8..6726fc678b6 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -203,7 +203,7 @@ async fn shield_memo_round_trips_through_ivk_decryption() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs index ffef155a4aa..ad51d2ea9c8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs @@ -40,6 +40,7 @@ use drive_proof_verifier::types::ShieldedEncryptedNote; use grovedb_commitment_tree::ExtractedNoteCommitment; use crate::wallet::shielded::keys::OrchardKeySet; +use crate::wallet::shielded::operations::shielded_actions; use crate::wallet::shielded::prover::CachedOrchardProver; use crate::wallet::shielded::store::{InMemoryShieldedStore, ShieldedStore, SubwalletId}; @@ -108,7 +109,7 @@ async fn shield_built_note_ovk_recovers_and_persists_as_outgoing() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; @@ -233,12 +234,14 @@ async fn live_recorder_builds_entry_from_real_shield_bundle() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { - panic!("expected a Shield state transition"); - }; + assert!(matches!( + &st, + StateTransition::Shield(ShieldTransition::V1(_)) + )); + let actions = shielded_actions(&st); let views = keys.viewing_keys(); - let cmxs = visible_output_cmxs(&v0.actions, &views); + let cmxs = visible_output_cmxs(actions, &views); assert!( !cmxs.is_empty(), "recorder must recover the wallet-visible output cmx from a real bundle" @@ -253,7 +256,7 @@ async fn live_recorder_builds_entry_from_real_shield_bundle() { fee: None, counterparty: None, memo: None, - actions: &v0.actions, + actions, spent_notes: &[], }, ); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs index fa9dafc2297..77f34ea0e67 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs @@ -110,7 +110,7 @@ async fn shield_built_note_is_trial_decryptable_by_own_ivk() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; @@ -198,7 +198,7 @@ async fn shield_to_external_recipient_decrypts_for_recipient_and_recovers_for_se .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; let wires: Vec = v0 diff --git a/packages/wasm-dpp2/src/shielded/shield_transition.rs b/packages/wasm-dpp2/src/shielded/shield_transition.rs index e2c347e13ca..1acaa940df9 100644 --- a/packages/wasm-dpp2/src/shielded/shield_transition.rs +++ b/packages/wasm-dpp2/src/shielded/shield_transition.rs @@ -7,13 +7,18 @@ use crate::platform_address::{ use crate::shielded::address_witness::{AddressWitnessWasm, input_witnesses_from_js_options}; use crate::shielded::orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; use crate::utils::try_vec_to_fixed_bytes; -use crate::utils::{try_from_options_optional_with, try_to_u16}; +use crate::utils::{try_from_options_optional, try_from_options_optional_with, try_to_u16}; +use crate::version::PlatformVersionWasm; use crate::{impl_wasm_conversions_inner, impl_wasm_type_info}; use dpp::prelude::UserFeeIncrease; use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; +use dpp::state_transition::shield_transition::v1::ShieldTransitionV1; use dpp::state_transition::{StateTransition, StateTransitionLike}; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionWitnessSigned}; +use dpp::version::PlatformVersion; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -33,6 +38,7 @@ export interface ShieldTransitionOptions { feeStrategy?: FeeStrategyStep[]; userFeeIncrease?: number; inputWitnesses: AddressWitness[]; + platformVersion?: PlatformVersion; } /** @@ -127,6 +133,11 @@ impl ShieldTransitionWasm { })? .unwrap_or(0); + let platform_version: PlatformVersion = + try_from_options_optional::(js_opts, "platformVersion")? + .map(Into::into) + .unwrap_or_else(|| PlatformVersionWasm::default().into()); + // Extract simple fields via serde (consumes options) let fields: ShieldTransitionSimpleFields = serde_wasm_bindgen::from_value(options.into()) @@ -139,112 +150,118 @@ impl ShieldTransitionWasm { let inputs_map = crate::platform_address::inputs_to_btree_map(inputs)?; let fee_strategy = fee_strategy_from_steps_or_default(fee_strategy); - Ok(ShieldTransitionWasm(ShieldTransition::V0( - ShieldTransitionV0 { + let actions = actions.into_iter().map(Into::into).collect(); + let input_witnesses = input_witnesses.into_iter().map(Into::into).collect(); + let transition = match platform_version + .dpp + .state_transition_serialization_versions + .shield_state_transition + .default_current_version + { + 0 => ShieldTransition::V0(ShieldTransitionV0 { inputs: inputs_map, - actions: actions.into_iter().map(Into::into).collect(), + actions, amount: fields.amount, anchor, proof: fields.proof, binding_signature, fee_strategy, user_fee_increase, - input_witnesses: input_witnesses.into_iter().map(Into::into).collect(), - }, - ))) + input_witnesses, + }), + 1 => ShieldTransition::V1(ShieldTransitionV1 { + inputs: inputs_map, + actions, + amount: fields.amount, + anchor, + proof: fields.proof, + binding_signature, + fee_strategy, + user_fee_increase, + input_witnesses, + }), + version => { + return Err(WasmDppError::invalid_argument(format!( + "unknown ShieldTransition version {version}" + ))); + } + }; + Ok(ShieldTransitionWasm(transition)) } /// Returns the input addresses funding the shield (with their nonces and amounts). #[wasm_bindgen(getter = "inputs")] pub fn inputs(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .inputs - .iter() - .map(|(address, (nonce, amount))| { - PlatformAddressInputWasm::new(*address, *nonce, *amount) - }) - .collect(), - } + self.0 + .inputs() + .iter() + .map(|(address, (nonce, amount))| { + PlatformAddressInputWasm::new(*address, *nonce, *amount) + }) + .collect() } /// Returns the serialized Orchard actions. #[wasm_bindgen(getter = "actions")] pub fn actions(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .actions - .iter() - .cloned() - .map(SerializedOrchardActionWasm::from) - .collect(), - } + self.0 + .actions() + .iter() + .cloned() + .map(SerializedOrchardActionWasm::from) + .collect() } /// Returns the shield amount (credits entering the pool). #[wasm_bindgen(getter = "amount")] pub fn amount(&self) -> u64 { - match &self.0 { - ShieldTransition::V0(v0) => v0.amount, - } + self.0.amount() } /// Returns the anchor (32-byte Merkle root). #[wasm_bindgen(getter = "anchor")] pub fn anchor(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0.anchor.to_vec(), - } + self.0.anchor().to_vec() } /// Returns the Halo2 proof bytes. #[wasm_bindgen(getter = "proof")] pub fn proof(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0.proof.clone(), - } + self.0.proof().to_vec() } /// Returns the RedPallas binding signature (64 bytes). #[wasm_bindgen(getter = "bindingSignature")] pub fn binding_signature(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0.binding_signature.to_vec(), - } + self.0.binding_signature().to_vec() } /// Returns the fee strategy steps. #[wasm_bindgen(getter = "feeStrategy")] pub fn fee_strategy(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .fee_strategy - .iter() - .cloned() - .map(FeeStrategyStepWasm::from) - .collect(), - } + self.0 + .fee_strategy() + .iter() + .cloned() + .map(FeeStrategyStepWasm::from) + .collect() } /// Returns the user fee increase multiplier. #[wasm_bindgen(getter = "userFeeIncrease")] pub fn user_fee_increase(&self) -> u16 { - match &self.0 { - ShieldTransition::V0(v0) => v0.user_fee_increase, - } + self.0.user_fee_increase() } /// Returns the input witnesses (signatures authorising each input). #[wasm_bindgen(getter = "inputWitnesses")] pub fn input_witnesses(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .input_witnesses - .iter() - .cloned() - .map(AddressWitnessWasm::from) - .collect(), - } + self.0 + .witnesses() + .iter() + .cloned() + .map(AddressWitnessWasm::from) + .collect() } #[wasm_bindgen(js_name = getModifiedDataIds)] diff --git a/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts b/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts index 00784f26ebb..1e05ce31d32 100644 --- a/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts +++ b/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts @@ -16,7 +16,7 @@ describe('ShieldTransition', () => { 0x00, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ]); - function createTransition() { + function createTransition(protocolVersion?: number) { const inputAddr = wasm.PlatformAddress.fromBytes(addrBytes); const input = new wasm.PlatformAddressInput(inputAddr, 0, BigInt(100_000)); const witness = wasm.AddressWitness.p2pkh(new Uint8Array(65)); @@ -29,10 +29,29 @@ describe('ShieldTransition', () => { proof: ZERO_PROOF, bindingSignature: ZERO_BINDING_SIG, inputWitnesses: [witness], + ...(protocolVersion === undefined ? {} : { + platformVersion: new wasm.PlatformVersion(protocolVersion), + }), }); } describe('constructor()', () => { + it('should select the signed format for explicit historical and current protocols', () => { + for (const [protocolVersion, formatVersion] of [[13, '0'], [14, '1']] as const) { + const transition = createTransition(protocolVersion); + expect(transition.toJSON().$formatVersion).to.equal(formatVersion); + const bytes = transition.toBytes(); + expect(Buffer.from(wasm.ShieldTransition.fromBytes(bytes).toBytes())) + .to.deep.equal(Buffer.from(bytes)); + expect(transition.toObject().inputWitnesses[0].signature) + .to.deep.equal(new Uint8Array(65)); + } + }); + + it('should default to the current platform format', () => { + expect(createTransition().toJSON().$formatVersion).to.equal('1'); + }); + it('should construct with required fields', () => { const t = createTransition(); expect(t).to.be.an.instanceof(wasm.ShieldTransition);