Component Pipeline
This chapter traces a request from the moment it leaves a client application to the moment its effects are committed to GroveDB. Understanding this pipeline is essential because every bug, every audit finding, and every performance issue lives somewhere along this path.
The Big Picture
Client App
|
| gRPC (protobuf)
v
DAPI (rs-dapi) -----------> Drive-ABCI query service (reads)
|
| BroadcastStateTransition
v
Tenderdash mempool
|
| ABCI (check_tx, prepare_proposal, process_proposal, finalize_block)
v
Drive-ABCI (rs-drive-abci)
|
| DriveOperations
v
Drive (rs-drive)
|
| GroveDB transaction
v
GroveDB -> RocksDB
There are two fundamentally different paths through this pipeline:
-
Reads (queries): Client sends a gRPC query, DAPI forwards it to Drive-ABCI's query service, which reads from GroveDB and returns data with a Merkle proof. No consensus involved.
-
Writes (state transitions): Client broadcasts a state transition, it enters the Tenderdash mempool, goes through consensus, and is applied to GroveDB during block processing.
Let's trace the write path in detail -- it is where all the complexity lives.
DAPI: The Entry Point
DAPI (packages/rs-dapi) is the internet-facing gRPC server. It implements two
roles:
- Platform queries: Forwarded directly to Drive-ABCI's gRPC service (which runs as a separate listener within the same process).
- State transition broadcast: Submitted to Tenderdash via its RPC interface.
// Simplified from packages/rs-dapi/src/services/platform_service/broadcast_state_transition.rs
Client --gRPC--> DAPI --Tenderdash RPC--> Tenderdash mempool
DAPI itself does minimal validation. It is a routing layer. The heavy lifting happens in Drive-ABCI.
The ABCI Handler Layer
When Tenderdash needs the application to do something -- check a transaction,
build a block, validate a proposal, or finalize a block -- it calls an ABCI
method. Drive-ABCI implements these in packages/rs-drive-abci/src/abci/handler/:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/handler/mod.rs mod check_tx; mod echo; mod extend_vote; mod finalize_block; mod info; mod init_chain; mod prepare_proposal; mod process_proposal; mod verify_vote_extension; }
These handlers are implemented against trait bounds, not concrete types:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/handler/finalize_block.rs pub fn finalize_block<'a, A, C>( app: &A, request: proto::RequestFinalizeBlock, ) -> Result<proto::ResponseFinalizeBlock, Error> where A: PlatformApplication<C> + TransactionalApplication<'a> + BlockExecutionApplication, C: CoreRPCLike, { ... } }
The FullAbciApplication struct wires everything together. It holds a reference
to Platform, a GroveDB transaction, and the current block execution context:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/app/full.rs pub struct FullAbciApplication<'a, C> { pub platform: &'a Platform<C>, pub transaction: RwLock<Option<Transaction<'a>>>, pub block_execution_context: RwLock<Option<BlockExecutionContext>>, } }
It implements the tenderdash_abci::Application trait, delegating each method
to the corresponding handler function.
Block Processing Lifecycle
Tenderdash uses a proposal-based consensus model. Here is the sequence of ABCI calls for a single block:
1. check_tx -- Mempool Gatekeeper
Before a state transition enters the mempool, Tenderdash calls check_tx. This
is a lightweight validation that runs outside of consensus:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/handler/check_tx.rs pub fn check_tx<C>( platform: &Platform<C>, core_rpc: &C, request: proto::RequestCheckTx, ) -> Result<proto::ResponseCheckTx, Error> where C: CoreRPCLike, { let platform_state = platform.state.load(); let platform_version = platform_state.current_platform_version()?; let validation_result = platform.check_tx( tx.as_slice(), r#type.try_into()?, &platform_ref, platform_version, ); // ... } }
check_tx operates in two modes: mode 0 (new transaction) and mode 1
(re-check existing mempool transactions after a new block). It returns a fee
estimate (gas_wanted), a priority for ordering, and a sender identifier for
deduplication. Importantly, check_tx does not run inside a GroveDB
transaction -- it reads committed state only.
2. prepare_proposal -- The Proposer Builds a Block
The block proposer calls prepare_proposal with a list of candidate
transactions. The handler starts a GroveDB transaction and runs the full block
proposal:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs // Start a GroveDB transaction app.start_transaction(); // Run the full proposal (validates + executes all state transitions) let mut run_result = app.platform().run_block_proposal( block_proposal, true, // known_from_us = true (we are the proposer) &platform_state, transaction, Some(&timer), )?; }
The response tells Tenderdash which transactions to keep, remove, or delay:
TxAction::Unmodified-- valid transition, include in blockTxAction::Removed-- unpaid error or internal error, strip from blockTxAction::Delayed-- exceeded max block size, try next block
3. process_proposal -- Validators Verify the Block
Non-proposing validators receive the block and call process_proposal. This
runs the same run_block_proposal logic but with known_from_us = false:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/handler/process_proposal.rs let run_result = app.platform().run_block_proposal( (&request).try_into()?, false, // known_from_us = false (we are validating someone else's proposal) &platform_state, transaction, None, )?; }
A key optimization: if the validator was also the proposer for this round (same
height and round), the cached result from prepare_proposal is reused:
#![allow(unused)] fn main() { // From process_proposal.rs -- cache hit path if let Some(proposal_info) = block_execution_context.proposer_results() { return Ok(proto::ResponseProcessProposal { status: proto::response_process_proposal::ProposalStatus::Accept.into(), app_hash: proposal_info.app_hash.clone(), tx_results: proposal_info.tx_results.clone(), // ... }); } }
If the proposal contains failed or unpaid transitions, the validator rejects it.
4. finalize_block -- Commit
After consensus is reached, Tenderdash calls finalize_block. This is where
the GroveDB transaction is committed to disk:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/abci/handler/finalize_block.rs let block_finalization_outcome = app.platform().finalize_block_proposal( request_finalize_block, block_execution_context, transaction, platform_version, )?; // Commit the GroveDB transaction let result = app.commit_transaction(platform_version); }
After commit, the block height counter is updated and, if needed, a GroveDB checkpoint is created for crash recovery.
Inside run_block_proposal
The run_block_proposal method in packages/rs-drive-abci/src/execution/engine/run_block_proposal/v0/mod.rs
is the heart of the block processing engine. It orchestrates everything that
happens within a single block. Here is the sequence, in order:
1. Verify protocol version matches expected version
2. Validate block follows previous block (height, core height)
3. Clear drive block cache
4. Verify chain lock (if core chain lock update present)
5. Update core info (masternode list, quorums)
6. Update validator proposed app version
7. Rebroadcast expired withdrawals
8. Update broadcasted withdrawal statuses
9. Dequeue and build unsigned withdrawal transactions
10. Run DAO platform events (vote tallying, contested documents)
11. Process raw state transitions <-- the main work
12. Store address balance changes
13. Clean up expired address balance entries
14. Pool withdrawals into transaction queue
15. Clean up expired withdrawal amount locks
16. Process block fees and validate sum trees
17. Compute root hash (app_hash)
18. Determine validator set update
Steps 1-10 are "block-level housekeeping." Step 11 is where individual state transitions are decoded, validated, transformed into actions, and applied to GroveDB. Steps 12-18 finalize the block's effects.
State Transition Processing
State transition processing (step 11 above) follows its own pipeline within
packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/:
decode_raw_state_transitions
|
v
process_raw_state_transitions
|
v
For each transition:
|
+-> validate (structure, state, signatures)
+-> transform_into_action
+-> validate_fees_of_event
+-> execute_event (apply DriveOperations to GroveDB)
The validation itself is split into modules per transition type:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs pub mod batch; pub mod identity_create; pub mod identity_credit_transfer; pub mod identity_credit_withdrawal; pub mod identity_top_up; pub mod identity_update; pub mod data_contract_create; pub mod data_contract_update; pub mod masternode_vote; // ... and more }
Each module provides versioned validation methods dispatched through
PlatformVersion, following the same pattern shown in the Introduction.
The Query Path
Queries bypass consensus entirely. Drive-ABCI implements the Platform gRPC service directly:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/query/service.rs // Implements dapi_grpc::platform::v0::platform_server::Platform // with methods like: // get_identity() // get_documents() // get_data_contract() // get_identity_balance() // ... (50+ query endpoints) }
Each query reads from the committed GroveDB state (no transaction), generates
a Merkle proof, and returns both the data and the proof. The client SDK uses
drive-proof-verifier to independently verify that the returned data matches
the proof against a known root hash.
Protocol Version Upgrades During Block Processing
One subtlety worth highlighting: protocol version changes happen at epoch
boundaries. The run_block_proposal method checks whether the current block is
the first block of a new epoch and whether the locked-in next protocol version
differs from the current one:
#![allow(unused)] fn main() { // From packages/rs-drive-abci/src/execution/engine/run_block_proposal/mod.rs let block_platform_version = if epoch_info.is_epoch_change_but_not_genesis() && platform_state.next_epoch_protocol_version() != platform_state.current_protocol_version_in_consensus() { let next_protocol_version = platform_state.next_epoch_protocol_version(); let next_platform_version = PlatformVersion::get(next_protocol_version)?; // Perform structural changes for the new protocol version self.perform_events_on_first_block_of_protocol_change( platform_state, &block_info, transaction, old_protocol_version, next_platform_version, )?; next_platform_version } else { last_committed_platform_version }; }
This ensures that all nodes switch protocol versions at exactly the same block, and that any structural migrations (new GroveDB trees, schema changes) are applied atomically as part of that block's transaction.
Rules
Do:
- Keep ABCI handlers thin. They should parse the request, delegate to the execution engine, and format the response. No business logic.
- Always pass
platform_versionthrough the call stack. Never hard-code a version number in execution logic. - Run
check_txvalidation as a strict subset of proposal validation. Ifcheck_txaccepts a transition,process_proposalshould not reject it (modulo state changes between the two calls).
Don't:
- Read uncommitted state in
check_tx. It reads committed state only because it runs outside the block transaction. - Assume
prepare_proposalandprocess_proposalsee the same state. Another block may have been committed between the two calls if a round change occurs. - Add new block-level events without inserting them in the correct position in
the
run_block_proposalsequence. Order matters -- withdrawals must be processed before state transitions, fee accounting must happen after. - Panic in ABCI handlers except for truly unrecoverable situations (like app hash mismatches, which indicate data corruption).