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_action partially succeeds but has warnings, the action is in data and the warnings are in errors.
  • is_valid() checks if errors is 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() or merge().
  • 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 Validator mode 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 IdentityUpdate from protocol version 15: it is refused unpaid, like a failed signature.
  • Use ConsensusValidationResult everywhere -- do not use Result<(), 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_tx and must be fast.
  • Skip is_allowed validation -- 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) with Error (a system error like a database failure). The former accumulates in ValidationResult::errors; the latter propagates via Result::Err.