advanced 30 min

The transaction processing pipeline

The four-phase validation pipeline (preflight → preclaim → doApply → finalization) and what each phase may and may not do.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Walk the four phases and their access levels.
  • Explain why `NotTEC` is returned from preflight.
  • Understand how failures at each stage are handled.
Complete this module by self-assessment and a quiz. Jump to assessment

Introduction

≈30 min · Advanced · builds on Transactor architecture

Every transaction runs a gauntlet before it can touch the ledger. In this module you'll walk the four-phase validation pipeline (preflight, preclaim, doApply and finalization) and learn exactly what each phase may and may not do, why preflight can never claim a fee, and how failures are handled at each stage. It's the beating heart of transaction processing, and the mental model behind every transactor you'll read.


Pipeline Overview

In brief: a transaction passes through four gates in order, and each gate has more access to the ledger, and more responsibility, than the last.

The pipeline has four distinct phases. Read the diagram top to bottom to follow a single transaction from arrival to commit:

The four-phase pipeline: a transaction arrives, preflight does stateless validation and returns NotTEC (failures reject with tel, tem, tef or ter), preclaim reads the ledger and verifies the key's signing authority and can return tec (failures reject or queue), doApply has full read/write access and modifies state (a tec charges the fee and reverts the changes), and finalization checks invariants, records metadata and commits

Key idea. Each gate has strictly more power than the one before it: stateless, then read-only, then read/write. That ordering is exactly what lets the node reject bad transactions cheaply, and only charge a fee once it is safe to.


Phase 1: Preflight

In brief: cheap, stateless checks on the transaction itself, run before anything expensive, and before a fee can ever be charged.

Preflight performs stateless validation, checks that depend only on the transaction content itself, not on any ledger state.

Characteristics

  • No ledger access: Only has access to PreflightContext (no view)
  • Deterministic: Same transaction always produces the same result
  • Can run in parallel: No shared state dependencies
  • Returns NotTEC: Cannot return tec codes (those require fee claiming)

What Preflight Checks

  1. Amendment/Feature enablement: Is the transaction type enabled?
  2. Flag validation: Are only valid flags set?
  3. Field presence: Are required fields present?
  4. Field format: Are field values well-formed?
  5. Field ranges: Are numeric values within valid ranges?
  6. Logical consistency: Are field combinations valid?

Implementation Pattern

Transaction-specific preflight is implemented as a static method. Notice that every check below reads only ctx.tx, never the ledger:

Since SendMax can now be either an IOU amount or an MPT amount, the asset sanity check is badAsset() == sendMax.asset() rather than the older currency-only comparison. CheckCreate also overrides checkExtraFeatures so that an MPT SendMax is rejected unless featureMPTokensV2 is enabled — an example of check #1 (amendment enablement) running before the transactor-specific preflight body.

Why Preflight Matters

  1. Early rejection: Catches obviously invalid transactions before expensive operations
  2. Security: preflight1 runs before any signature work; preflight2 then does the cryptographic signature check, still stateless, so no ledger resources are touched for garbage input
  3. Cannot claim fees: Returns NotTEC, so no fees can be claimed for malformed transactions

Watch out. Preflight cannot return tec codes because those codes claim the transaction fee, and preflight has no ledger view to charge anything against. It is stateless by design: preflight1 checks the format, preflight2 checks that the signature is cryptographically valid, and neither can touch an account.


Phase 2: Preclaim

In brief: read-only checks against real ledger state. The signature's cryptographic validity was proven in preflight2; preclaim's checkSign now verifies the key's authority for the account (master key, regular key, or signer list). This is the first phase that can charge a fee.

Preclaim performs ledger-state validation with read-only access to the ledger.

Characteristics

  • Read-only ledger access: Has ReadView const& view
  • Signing authority verified: checkSign confirms the (already cryptographically valid) signature comes from a key allowed to sign for this account
  • Can return tec codes: Fee can be claimed
  • Feeds, but does not make, the queue decision: whether a transaction is queued (terQUEUED) or applied is decided by TxQ (src/xrpld/app/misc/detail/TxQ.cpp), which consumes the pipeline's results — there is no queueing logic inside preclaim itself

What Preclaim Checks

  1. Account existence: Does the sender account exist?
  2. Sequence number: Is the sequence correct?
  3. Fee adequacy: Is the fee sufficient?
  4. Balance sufficiency: Can the account pay the fee?
  5. Destination checks: Does destination exist? Are permissions met?
  6. Trust line state: Are trust lines frozen? Is authorization required?
  7. Expiration: Has the transaction or related object expired?

Implementation Pattern

The same transactor, one phase later. Now it reads ledger state through ctx.view (read-only), and can return tec codes:

Two details worth pausing on. The lsfDisallowIncomingCheck test is unconditional: the DisallowIncoming amendment is retired (XRPL_RETIRE_FEATURE(DisallowIncoming) in include/xrpl/protocol/detail/features.macro), so there is no rules().enabled(...) gate around it any more. And the global-freeze test goes through checkGlobalFrozen(view, asset), which returns a TER directly: tecFROZEN for a globally frozen IOU, tecLOCKED for a locked MPT.

Key Utility Functions for Preclaim

These helpers do the read-only ledger lookups preclaim relies on:

Function Purpose
ctx.view.read(keylet) Read a ledger entry
isGlobalFrozen(view, issuer) Check if an issuer has globally frozen
checkGlobalFrozen(view, asset) Same check as a TER: tecFROZEN (IOU) or tecLOCKED (MPT)
isFrozen(view, account, issue) Check if a specific trust line is frozen
hasExpired(view, expiration) Check if a time has passed
isPseudoAccount(sle) Check if account is a pseudo-account (AMM, etc.)

Phase 3: doApply

In brief: the only phase that actually changes the ledger, and it does so all-or-nothing.

doApply performs the actual ledger modifications. This is where state changes happen.

Characteristics

  • Full read/write access: Has ApplyView& view()
  • Atomic execution: All changes succeed or all are reverted
  • Can create, update, delete ledger entries
  • Must handle reserve requirements

Implementation Pattern

The final phase. Watch it check the reserve first, then create the Check entry and wire it into the destination's and the owner's directories:

Note the reserve check compares against preFeeBalance_, not the current sfBalance — because by the time doApply runs, the fee has already been deducted (see Phase 4). Using the pre-fee balance is what lets an account dip into its reserve to pay fees. Also note the journal comes from ctx_.registry.get().getJournal("View") — ApplyContext holds a ServiceRegistry reference, not an Application.

Key Operations in doApply

The read/write operations a transactor uses to change ledger state:

Operation Method Description
Read entry view().peek(keylet) Get modifiable reference to entry
Create entry view().insert(sle) Add new entry to ledger
Update entry view().update(sle) Mark entry as modified
Delete entry view().erase(sle) Remove entry from ledger
Add to directory view().dirInsert(...) Add entry to owner directory
Update owner count adjustOwnerCount(...) Increment/decrement owner count

Phase 4: Finalization

In brief: the engine, not your transactor, handles fee, sequence, invariants, metadata and commit — and the fee and sequence are taken before doApply, not after.

A common misconception is that the fee is deducted and the sequence incremented after doApply succeeds. In the code, both happen before doApply runs. Transactor::apply() (src/libxrpl/tx/Transactor.cpp) captures the starting balance, consumes the sequence (or ticket), pays the fee, and only then calls doApply():

This is why doApply's reserve check uses preFeeBalance_: the fee is already gone from sfBalance when doApply runs.

After doApply returns, Transactor::operator()() finalizes the result:

  1. Invariant checks: The invariant checkers verify the changes did not violate protocol invariants
  2. Failure rollback via reset(): For tec results and invariant failures, reset(fee) calls ctx_.discard() to throw away every change the transaction made — including the fee deduction and sequence consumption from apply() — and then re-applies exactly those two: it deducts the fee from sfBalance and calls consumeSeqProxy again. That is how a tec transaction ends up changing nothing but the fee and the sequence
  3. Metadata recording: ctx_.apply(result) records the changes in transaction metadata
  4. Fee accounting: ctx_.destroyXRP(fee) accounts for the destroyed fee in the ledger header — as the comment in operator()() puts it, "The fee has already been deducted from the balance of the account that issued the transaction. We just need to account for it in the ledger header."

This phase is handled by the engine, not by individual transactors.


Error Propagation

In brief: which result codes each phase may return, and exactly when a fee sticks.

Different phases can return different categories of result codes:

Phase Can Return Fee Charged? Notes
Preflight tel*, tem*, tef*, ter*, tes No No tec codes allowed
Preclaim All codes For tec* only May queue for ter*
doApply All codes For tec*, tes Changes reverted for tec*

Practical Example: Tracing a CheckCreate

In brief: the same CheckCreate followed gate by gate, so you can watch the theory run.

Take this transaction and follow it through all four phases:

Transaction: CheckCreate
  Account: rAlice
  Destination: rBob
  SendMax: 100 XRP
  Expiration: 750000000

Preflight:

  1. Check rAlice != rBob → Pass
  2. Validate 100 XRP is positive and legal → Pass
  3. Validate expiration 750000000 != 0 → Pass
  4. Result: tesSUCCESS

Preclaim:

  1. Read Bob's account → Exists
  2. Check lsfDisallowIncomingCheck → Not set
  3. Check isPseudoAccount(Bob) → False
  4. Check lsfRequireDestTag → Not set (or tag provided)
  5. Check checkGlobalFrozen → SendMax is XRP (native), check skipped
  6. Check hasExpired(750000000) → Not expired
  7. Result: tesSUCCESS

Before doApply (in Transactor::apply()):

  1. Capture Alice's balance into preFeeBalance_
  2. Consume Alice's sequence (consumeSeqProxy)
  3. Deduct the fee from Alice's balance (payFee)

doApply:

  1. Read Alice's account (peek)
  2. Calculate reserve for +1 owner count → e.g., 1.2 XRP at the current mainnet values (1 XRP base + 0.2 XRP owner increment; reserves are set by validator fee voting, so the numbers are network-configured, not hard-coded)
  3. Check preFeeBalance_ >= reserve → Pass
  4. Create Check SLE with all fields
  5. Insert Check into ledger
  6. Add to Bob's owner directory → Get page number (sfDestinationNode)
  7. Add to Alice's owner directory → Get page number (sfOwnerNode)
  8. Increment Alice's owner count
  9. Result: tesSUCCESS

Finalization:

  1. Run invariant checks (a tec or invariant failure here would trigger reset(fee): discard everything, re-apply fee and sequence)
  2. Record metadata (ctx_.apply(result))
  3. Account for the destroyed fee in the ledger header (ctx_.destroyXRP(fee))

Best Practices for Implementing Transactors

In brief: the habits that keep a transactor correct, cheap, and safe.

  1. Preflight should be stateless: Never access ledger state in preflight
  2. Fail early: Check the cheapest conditions first
  3. Use appropriate error codes: tem* for format errors, tec* for state-dependent failures
  4. Check reserves before creating objects: Use preFeeBalance_ (the balance captured before payFee() ran) so accounts can dip into the reserve to pay fees
  5. Always update owner count: When creating or deleting owned objects
  6. Always update directories: Add/remove from owner directories
  7. Log warnings: Use JLOG to help with debugging

Codebase References

Where this pipeline lives in the source:

File Description
src/libxrpl/tx/applySteps.cpp Transaction dispatch and phase orchestration
src/libxrpl/tx/apply.cpp Core apply logic
src/libxrpl/tx/Transactor.cpp Base class phase implementations

Summary

This module walked the four-phase validation pipeline: preflight (stateless), preclaim (read-only state), doApply (read/write state), and finalization. You learned exactly what each phase may and may not do, why preflight can never claim a fee (it is stateless, with no ledger to charge against), how the signature is checked in two layers (cryptographic validity in preflight2, signing authority in preclaim's checkSign), and how failures are handled at each stage, with the fee sticking only from preclaim onward. Each gate has strictly more access than the one before, which is what makes early rejection cheap and safe.

To remember:

  • Order: preflight, then preclaim, then doApply, then finalization; each gate has strictly more access
  • preflight: stateless; preflight1 checks format, preflight2 checks the signature cryptographically; returns NotTEC (no tec possible)
  • preclaim: read-only ledger view; checkSign verifies signing authority (master/regular/signer list); can return tec
  • doApply: the only writer, all-or-nothing through the ApplyView
  • Fee and sequence are engine work, not the transactor's — Transactor::apply() runs consumeSeqProxy and payFee before doApply; after doApply the engine checks invariants, records metadata and commits, and on tec results reset(fee) discards everything and re-applies only the fee and sequence
  • A fee is charged only for tes and tec outcomes
  • Dispatch: src/libxrpl/tx/applySteps.cpp; shared phase logic: src/libxrpl/tx/Transactor.cpp
  • Watch out: tec from preflight would let attackers drain fees with unsigned junk; that is exactly why NotTEC exists

Next up. The gates are clear; now look at what every passage costs. Next: state modification, fees and sequences, the bookkeeping that no transaction escapes.

Assignments

0 of 2 complete

XRPL Academy © 2026