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 (on the first check, also the proofs of possession of the keys an identity update adds), basic structure, and balance checks. The goal is to filter out obvious garbage cheaply, without doing expensive state lookups.
A MasternodeVote is the exception. A block refuses a failed vote without charging anyone,
and the proposer drops it from the block without a trace, so check_tx runs the vote's advanced
structure and state validation as well. A vote that a block would refuse, such as a Lock vote
on a contest without locking, is refused when it is broadcast, with its error.
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_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( &self, platform_version: &PlatformVersion, ) -> Result<ConsensusValidationResult<()>, Error>; } }
Transitions available since protocol version 1, like DataContractCreate,
IdentityCreate and Batch, skip this check entirely.
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 nonce must be unused for that identity (or identity-contract pair) and fall within the bounded nonce window. Nonces can be accepted out of order; see Identity Nonces. 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)); } } }
A MasternodeVote is paid by its vote poll's prefunded specialized balance, not by
the voter, so its pre-check is on that pot: a vote on a poll whose pot does not exist,
or holds less than the single vote cost, is refused unpaid with
PrefundedSpecializedBalanceNotFoundError or
PrefundedSpecializedBalanceInsufficientError.
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 usually 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, so a structurally invalid transition cannot be replayed over and over without
cost. IdentityUpdate from protocol version 15 has one exception, described below.
For IdentityUpdate this stage checks the revision and the proof of possession of each added
key. The identity signs the transition's signable bytes, which leave out the signatures of the
keys it adds. From protocol version 15 an update whose added key's proof of possession does not
verify (a key whose public key data does not decode fails it too) is refused without a fee and
without using the nonce, as a failed identity signature is, and no key is added; a revision
mismatch is still charged. A block holding such a transition is refused, as for any unpaid
result. check_tx verifies the proofs of possession of the added keys at every protocol version,
after the identity signature, so such a transition does not reach a block; it leaves the revision
to the block, as an update queued behind another of the same identity does not match the stored
revision yet.
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.
For IdentityCreateFromAddresses this stage also checks the proof of possession of each key
the transition registers. The address witnesses cannot sign those signatures (both sign the
same bytes), so the owners of the inputs never signed what this check judges. From protocol
version 14 a failure is therefore refused without a fee, like a failed witness, instead of
charging the inputs a penalty; check_tx runs this stage for the transition too, so such a
transition does not reach a block.
For IdentityCreate this stage checks that the identity id is the one the asset lock's outpoint
derives, the structure of the keys, and the proof of possession of each key. The asset lock
signature covers the keys but neither the identity id nor the key signatures, which are left
out of the signable bytes. From protocol version 14 an identity id or proof of possession
failure is therefore refused without a fee instead of charging the asset lock a penalty, and
the asset lock is left as it was; a key structure failure is still charged. A block holding
such a transition is refused, as for any unpaid result. check_tx runs this stage for
IdentityCreate too, at every protocol version, so such a transition does not reach a block; it
checks the identity id first, before verifying the asset lock proof.
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. The exception is a failure in a field the signer did not sign, such as
an added key's proof of possession on an
IdentityUpdatefrom protocol version 15: it is refused unpaid, like a failed signature. - 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.