Transform Into Action
After a state transition passes the early validation stages -- signature verification, nonce checks, basic structure -- the platform needs to translate it from a raw "what the client sent" representation into a "what the platform should do" representation. This translation step is called transform into action, and it is where the platform reads state, resolves references, and prepares a validated, self-contained instruction for the Drive storage layer.
Why Actions Exist
Consider a DataContractCreateTransition. The client sends the contract in its
serialized form. But before the platform can store it, it needs to:
- Deserialize the contract from its wire format
- Validate the contract schema (JSON Schema validation, index rules, etc.)
- Compute the contract ID from the owner ID and nonce
- Check version compatibility
The transition is what the client sends. The action is what the platform will execute. The action is a validated, resolved, ready-to-apply object. It has already been through deserialization, its references have been resolved against current state, and it carries exactly the information needed to generate Drive operations.
This separation is a deliberate design choice. The transition type lives in rs-dpp
(the protocol library, shared between client and platform). The action type lives in
rs-drive (the storage layer, platform-only). By keeping them separate:
- Clients never need to depend on storage internals
- The platform can evolve its internal representation without changing the wire format
- Validation logic lives close to state access, not scattered across the protocol layer
The StateTransitionActionTransformer Trait
The transformation is defined by a trait in
packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs:
#![allow(unused)] fn main() { pub trait StateTransitionActionTransformer { fn transform_into_action<C: CoreRPCLike>( &self, platform: &PlatformRef<C>, block_info: &BlockInfo, remaining_address_input_balances: &Option< BTreeMap<PlatformAddress, (AddressNonce, Credits)>, >, validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result<ConsensusValidationResult<StateTransitionAction>, Error>; } }
The key parameters:
platform-- Access to the full platform state, including Drive (the storage layer), the current platform state, and configuration.block_info-- The current block height, time, epoch. Some validations are time-dependent.remaining_address_input_balances-- For address-based transitions, the balances remaining after earlier deductions.validation_mode-- Controls how thorough the validation is (lighter forCheckTx, full forValidator).execution_context-- Accumulates information about what operations were performed during validation (used for fee estimation).tx-- The GroveDB transaction handle for consistent state reads.
The return type is ConsensusValidationResult<StateTransitionAction> -- it can carry
both the resulting action and any validation errors encountered during transformation.
The Top-Level Dispatch
The StateTransition enum implements StateTransitionActionTransformer by dispatching
to each variant's own implementation:
#![allow(unused)] fn main() { impl StateTransitionActionTransformer for StateTransition { fn transform_into_action<C: CoreRPCLike>( &self, platform: &PlatformRef<C>, block_info: &BlockInfo, remaining_address_input_balances: &Option<BTreeMap<PlatformAddress, (AddressNonce, Credits)>>, validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result<ConsensusValidationResult<StateTransitionAction>, Error> { match self { StateTransition::DataContractCreate(st) => st.transform_into_action( platform, block_info, remaining_address_input_balances, validation_mode, execution_context, tx, ), StateTransition::Batch(st) => st.transform_into_action( platform, block_info, remaining_address_input_balances, validation_mode, execution_context, tx, ), StateTransition::IdentityCreate(st) => { let signable_bytes = self.signable_bytes()?; st.transform_into_action_for_identity_create_transition( platform, signable_bytes, validation_mode, execution_context, tx, ) }, StateTransition::IdentityTopUp(st) => { let signable_bytes = self.signable_bytes()?; st.transform_into_action_for_identity_top_up_transition( platform, signable_bytes, validation_mode, execution_context, tx, ) }, // ... remaining variants } } } }
Notice that IdentityCreate and IdentityTopUp use specialized method names
(transform_into_action_for_identity_create_transition) and pass signable_bytes
explicitly. This is because these transitions need the signable bytes to verify
the asset lock proof signature, and computing them at the StateTransition level
(before the inner struct is extracted) ensures the correct bytes are used.
The StateTransitionAction Enum
The output of transformation is a StateTransitionAction, defined in
packages/rs-drive/src/state_transition_action/mod.rs:
#![allow(unused)] fn main() { pub enum StateTransitionAction { DataContractCreateAction(DataContractCreateTransitionAction), DataContractUpdateAction(DataContractUpdateTransitionAction), BatchAction(BatchTransitionAction), IdentityCreateAction(IdentityCreateTransitionAction), IdentityTopUpAction(IdentityTopUpTransitionAction), IdentityCreditWithdrawalAction(IdentityCreditWithdrawalTransitionAction), IdentityUpdateAction(IdentityUpdateTransitionAction), IdentityCreditTransferAction(IdentityCreditTransferTransitionAction), MasternodeVoteAction(MasternodeVoteTransitionAction), // ... address-based actions ... BumpIdentityNonceAction(BumpIdentityNonceAction), BumpIdentityDataContractNonceAction(BumpIdentityDataContractNonceAction), PartiallyUseAssetLockAction(PartiallyUseAssetLockAction), BumpAddressInputNoncesAction(BumpAddressInputNoncesAction), } }
Notice the system actions at the bottom: BumpIdentityNonceAction,
BumpIdentityDataContractNonceAction, PartiallyUseAssetLockAction, and
BumpAddressInputNoncesAction. These are not user-requested actions. They are
generated by the platform when a transition fails validation after the nonce check.
The platform still needs to bump the nonce (so the same invalid transition cannot
be replayed) and charge a fee. These system actions handle that housekeeping.
Versioned Dispatch Within transform_into_action
Each transition type's transform_into_action implementation uses the standard
versioned dispatch pattern. Here is the data contract create example from
packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs:
#![allow(unused)] fn main() { impl StateTransitionActionTransformer for DataContractCreateTransition { fn transform_into_action<C: CoreRPCLike>( &self, platform: &PlatformRef<C>, block_info: &BlockInfo, _remaining_address_input_balances: &Option< BTreeMap<PlatformAddress, (AddressNonce, Credits)>, >, validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, _tx: TransactionArg, ) -> Result<ConsensusValidationResult<StateTransitionAction>, Error> { let platform_version = platform.state.current_platform_version()?; match platform_version .drive_abci .validation_and_processing .state_transitions .contract_create_state_transition .transform_into_action { 0 => self.transform_into_action_v0::<C>( block_info, validation_mode, execution_context, platform_version, ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "data contract create transition: transform_into_action".to_string(), known_versions: vec![0], received: version, })), } } } }
The version number (0 here) is looked up from the platform version struct, allowing
the logic to change in future protocol upgrades without modifying the dispatch layer.
What Happens Inside: Reading State
The core work inside transform_into_action varies by transition type, but the
common thread is resolving references against current state. Here are the key
patterns for different transition types:
DataContractCreate: Deserializes the contract from its wire format, validates the
schema, and wraps it in a DataContractCreateTransitionAction. The validation depth
depends on ValidationMode -- CheckTx mode skips full schema validation for speed.
DataContractUpdate: Fetches the existing contract from Drive to compare against the proposed update. Validates that schema changes are backward-compatible (e.g., you can add fields but not remove required ones).
Batch (Documents): This is the most complex transformation. For each document sub-transition in the batch, the platform must:
- Fetch the data contract that the document belongs to
- Look up the document type within that contract
- For creates: validate the document against the type's schema
- For replaces/deletes: fetch the existing document to verify ownership and revision
- For token operations: validate token configuration and authorization
IdentityCreate: Validates the asset lock proof against the core chain (via RPC), verifies the signature on the transition using keys embedded in the transition itself, and constructs the identity that will be created.
MasternodeVote: Fetches the contested resource being voted on, verifies that the masternode is eligible to vote, and constructs the vote action.
The Batch Transition: A Deeper Look
The BatchTransition is worth examining more closely because it demonstrates how
transform_into_action handles multiple sub-operations. A single batch can contain
dozens of document and token transitions targeting different contracts and document
types.
During transformation, the platform:
- Groups transitions by contract. This allows fetching each contract only once.
- Fetches all needed contracts. Each contract is loaded from Drive (with caching).
- Resolves each sub-transition. Document creates get validated against their type's schema. Replaces fetch the existing document. Token operations check authorization rules.
- Produces a
BatchTransitionActioncontaining individualBatchedTransitionActionitems -- each of which is either aDocumentActionor aTokenAction.
If any sub-transition fails validation, the entire batch fails, and the platform
produces a BumpIdentityDataContractNonceAction instead (bumping the nonce to
prevent replay, while charging the user).
When Transformation Fails
Transformation can fail in two ways:
System error (Result::Err): Something unexpected happened -- a database read
failed, a version mismatch, corrupted state. These propagate up as Error and
typically halt block processing.
Consensus error (ValidationResult with errors): The transition itself is invalid.
The document does not match its schema, the contract does not exist, the asset lock is
already spent. In this case, the result still carries data -- typically a nonce-bump
action -- so the pipeline can charge the user and move on.
This distinction is reflected in the return type:
Result<ConsensusValidationResult<StateTransitionAction>, Error>. The outer Result
is for system errors. The inner ConsensusValidationResult is for user errors.
Rules and Guidelines
Do:
- Always respect
ValidationMode. Skip expensive work duringCheckTxbut never skip it duringValidatormode. - Accumulate validation costs in
execution_context-- every state read, every schema validation -- so that fee calculation is accurate. - Return a nonce-bump action when transformation fails for user-caused reasons. Never silently swallow the transition.
- Fetch contracts through Drive's caching layer. A batch transition might reference the same contract dozens of times; fetching it from disk each time would be prohibitively expensive.
Do not:
- Perform state writes during transform_into_action. This is a read-only phase. State is only modified when the resulting action is applied later.
- Mix up the transition and action types. The transition is
DataContractCreateTransition(fromrs-dpp). The action isDataContractCreateTransitionAction(fromrs-drive). They live in different crates for good reason. - Forget to handle the
remaining_address_input_balancesparameter for address-based transitions. It isNonefor identity-based transitions but must beSomefor address-based ones -- failing to provide it is a corrupted code execution error. - Add new transition types without implementing both the transformer trait and
adding the variant to the
StateTransitionActionenum. These must stay in sync.