The Validation Pipeline
When a state transition arrives at a Dash Platform node, it does not get applied immediately. It must survive a gauntlet of validation stages, each designed to catch a different category of error. Understanding this pipeline is essential to understanding how the platform maintains security, prevents abuse, and ensures deterministic state across all validators.
Two Entry Points: check_tx and process_proposal
State transitions enter the pipeline through two different doors, depending on when they are being validated.
check_tx runs when a transition first arrives at a node (before entering the mempool) and again when rechecking mempool contents. It performs a lighter validation: signature verification, basic structure, and balance checks. The goal is to filter out obvious garbage cheaply, without doing expensive state lookups.
This is implemented in
packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/mod.rs:
#![allow(unused)] fn main() { pub(in crate::execution) fn state_transition_to_execution_event_for_check_tx<'a, C: CoreRPCLike>( platform: &'a PlatformRef<C>, state_transition: StateTransition, check_tx_level: CheckTxLevel, platform_version: &PlatformVersion, ) -> Result<ConsensusValidationResult<Option<ExecutionEvent<'a>>>, Error> { ... } }
process_proposal (also called the "Validator" path) runs during block execution.
This is the full pipeline. It is implemented in
packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs:
#![allow(unused)] fn main() { pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( platform: &'a PlatformRef<C>, block_info: &BlockInfo, state_transition: StateTransition, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<ConsensusValidationResult<ExecutionEvent<'a>>, Error> { ... } }
Let us walk through the full pipeline, stage by stage.
Stage 1: Is Allowed
Some state transition types are only available starting from a certain protocol version.
For example, address-based transitions like IdentityCreateFromAddresses require
protocol version 11 or higher. The first check asks: is this transition type even
permitted on the current network?
#![allow(unused)] fn main() { if state_transition.has_is_allowed_validation()? { let result = state_transition.validate_is_allowed(platform, platform_version)?; if !result.is_valid() { return Ok(ConsensusValidationResult::new_with_errors(result.errors)); } } }
The trait is defined in
packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs:
#![allow(unused)] fn main() { pub(crate) trait StateTransitionIsAllowedValidationV0 { fn has_is_allowed_validation(&self) -> Result<bool, Error>; fn validate_is_allowed<C: CoreRPCLike>( &self, platform: &PlatformRef<C>, platform_version: &PlatformVersion, ) -> Result<ConsensusValidationResult<()>, Error>; } }
Transitions like DataContractCreate and IdentityCreate skip this check entirely
(they have always been allowed). The Batch transition has its own is_allowed logic
because certain token operations may be gated.
Stage 2: Identity Signature Verification
For identity-signed transitions (everything except IdentityCreate, IdentityTopUp,
and the address-based types), the platform fetches the signer's identity from state
and verifies the signature against one of its registered public keys.
#![allow(unused)] fn main() { let mut maybe_identity = if state_transition.uses_identity_in_state() { 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 { state_transition.retrieve_identity_info(...) }?; if !result.is_valid() { return Ok(ConsensusValidationResult::new_with_errors(result.errors)); } Some(result.into_data()?) } else { None }; }
If the signature does not match, the transition is rejected without charging a fee. This is critical: if someone forges a transition with your identity ID but a wrong signature, you should not pay for their garbage.
Stage 3: Address Witness Validation
For address-based transitions, instead of identity signatures, the platform validates witnesses (proofs of ownership) for the input addresses:
#![allow(unused)] fn main() { 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() { return Ok(ConsensusValidationResult::new_with_errors(result.errors)); } } }
Stage 4: Address Balances and Nonces
For transitions that use platform addresses as inputs, the platform verifies that each input address has sufficient balance and that the provided nonces are correct (preventing replay):
#![allow(unused)] fn main() { let remaining_address_balances = if state_transition.has_addresses_balances_and_nonces_validation() { let result = state_transition.validate_address_balances_and_nonces( platform.drive, &mut state_transition_execution_context, transaction, platform_version, )?; if !result.is_valid() { return Ok(ConsensusValidationResult::new_with_errors(result.errors)); } Some(result.into_data()?) } else { None }; }
Stage 5: Identity Nonce Validation
Nonces prevent replay attacks. Each identity-signed transition carries a nonce that must be strictly greater than the last used nonce for that identity (or identity-contract pair). The platform checks this against the stored nonce in state.
#![allow(unused)] fn main() { if state_transition.has_identity_nonce_validation(platform_version)? { 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() { return Ok(ConsensusValidationResult::new_with_errors(result.errors)); } } }
Identity create and identity top up skip this check -- they do not have nonces because the identity may not exist yet.
Stage 6: Basic Structure Validation
This stage checks that the transition's data is well-formed without looking at platform state. For example: are all required fields present? Are field values within allowed ranges? Is the data contract schema valid JSON Schema?
#![allow(unused)] fn main() { if state_transition.has_basic_structure_validation(platform_version) { let consensus_result = state_transition.validate_basic_structure( platform.config.network, platform_version, )?; if !consensus_result.is_valid() { return Ok(ConsensusValidationResult::new_with_errors( consensus_result.errors, )); } } }
The trait lives in processor/traits/basic_structure.rs:
#![allow(unused)] fn main() { pub(crate) trait StateTransitionBasicStructureValidationV0 { fn validate_basic_structure( &self, network_type: Network, platform_version: &PlatformVersion, ) -> Result<SimpleConsensusValidationResult, Error>; fn has_basic_structure_validation(&self, _platform_version: &PlatformVersion) -> bool { true } } }
MasternodeVote skips basic structure validation entirely. DataContractCreate and
DataContractUpdate conditionally enable it based on the platform version.
Stage 7: Balance Pre-Check
Before doing expensive state validation, the platform checks that the identity has enough credits to plausibly pay for this transition. For credit transfers and withdrawals, this includes checking that the transfer amount plus estimated fees does not exceed the balance.
#![allow(unused)] fn main() { if state_transition.has_identity_minimum_balance_pre_check_validation() { 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)); } } }
Stage 8: Advanced Structure Validation (without State)
Some transitions need structural validation that goes beyond basic checks but does not
require reading from platform state. For example, IdentityUpdate verifies that
added public keys have valid signatures. DataContractCreate validates the contract
schema more deeply.
#![allow(unused)] fn main() { if state_transition.has_advanced_structure_validation_without_state() { let consensus_result = state_transition.validate_advanced_structure( identity, &mut state_transition_execution_context, platform_version, )?; if !consensus_result.is_valid() { // Note: this returns an action so the nonce gets bumped even on failure return consensus_result.map_result(|action| { ExecutionEvent::create_from_state_transition_action(...) }); } } }
An important detail: if advanced structure validation fails, the identity's nonce is still bumped. This prevents an attacker from replaying a structurally invalid transition over and over without cost.
Stage 9: Transform Into Action + Advanced Structure (with State)
For certain transition types (Batch, IdentityCreate, MasternodeVote,
AddressFundingFromAssetLock, IdentityCreateFromAddresses), the platform needs
to read state before it can finish structure validation. For example, a document
create transition needs to fetch the data contract to validate the document against
its schema.
This stage first transforms the raw state transition into an action (reading state in the process), then validates the action's structure:
#![allow(unused)] fn main() { let action = if state_transition.has_advanced_structure_validation_with_state() { let state_transition_action_result = state_transition.transform_into_action( platform, block_info, &remaining_address_balances, 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| { ... }); } let action = state_transition_action_result.into_data()?; 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| { ... }); } Some(action) } else { None }; }
We will cover transform_into_action in detail in the next chapter.
Stage 10: State Validation
The final validation stage checks for state-level conflicts. Does a data contract with this ID already exist? Is there already a document with this unique index value? Has this asset lock already been spent?
#![allow(unused)] fn main() { 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 transitions that skipped earlier }; }
Not all transitions need state validation. IdentityTopUp, IdentityCreditWithdrawal,
AddressFundsTransfer, and several others skip it -- their validation is fully covered
by the earlier stages.
ConsensusValidationResult: How Errors Accumulate
Throughout the pipeline, errors are communicated through ConsensusValidationResult,
defined in packages/rs-dpp/src/validation/validation_result.rs:
#![allow(unused)] fn main() { pub type ConsensusValidationResult<TData> = ValidationResult<TData, ConsensusError>; pub type SimpleConsensusValidationResult = ConsensusValidationResult<()>; pub struct ValidationResult<TData: Clone, E: Debug> { pub errors: Vec<E>, pub data: Option<TData>, } }
Key properties of this type:
- It can carry data alongside errors. When
transform_into_actionpartially succeeds but has warnings, the action is indataand the warnings are inerrors. is_valid()checks iferrorsis empty. Any error means failure.is_valid_with_data()checks both. Valid and has associated data.- Errors can be merged from multiple validation steps using
add_errors()ormerge(). - Chainable via
and_then_validation()for pipeline-style composition.
The SimpleConsensusValidationResult alias (where TData = ()) is used by validation
stages that just check pass/fail without producing a transformed object.
ValidationMode
The pipeline behavior varies based on context:
#![allow(unused)] fn main() { pub enum ValidationMode { CheckTx, // Mempool admission -- lighter checks RecheckTx, // Periodic mempool revalidation Validator, // Full block execution NoValidation, // Testing/tooling only } }
For example, should_fully_validate_contract_on_transform_into_action() returns
true only in Validator mode. During CheckTx, the platform skips expensive
contract validation to keep mempool admission fast.
The Early-Return Pattern
You will notice a consistent pattern throughout the pipeline: each stage checks
is_valid() and returns early if validation failed. This is intentional.
When the pipeline returns early due to a signature failure or invalid nonce, the transition produces no execution event -- the user is not charged. This protects users from being billed for transitions they did not actually submit (forged signatures) or that are replays (invalid nonces).
But when the pipeline returns early after the nonce has been validated -- for example, during advanced structure validation or state validation -- it returns a nonce-bump action so the identity still pays a small fee. This prevents a different attack: submitting thousands of structurally invalid transitions to waste validator resources for free.
Rules and Guidelines
Do:
- Run the full pipeline in
Validatormode during block execution. Skipping stages can lead to consensus divergence. - Return early on signature/nonce failures without charging the user.
- Bump the nonce (and charge) when structure or state validation fails after the nonce check has passed.
- Use
ConsensusValidationResulteverywhere -- do not useResult<(), Error>for validation outcomes, because a validation failure is not a system error.
Do not:
- Add expensive state reads to basic structure validation. It runs during
check_txand must be fast. - Skip
is_allowedvalidation -- it is the mechanism that enables protocol upgrades to gate new transition types. - Assume the pipeline order is arbitrary. Each stage depends on information established by previous stages (e.g., the identity fetched during signature validation is used in balance checks).
- Confuse
ConsensusError(a validation failure that the user caused) withError(a system error like a database failure). The former accumulates inValidationResult::errors; the latter propagates viaResult::Err.