The four-phase validation pipeline (preflight → preclaim → doApply → finalization) and what each phase may and may not do.
What you'll learn
≈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.
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:
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.
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.
PreflightContext (no view)NotTEC: Cannot return tec codes (those require fee claiming)Transaction-specific preflight is implemented as a static method. Notice that every check below reads only ctx.tx, never the ledger:
NotTEC
CheckCreate::preflight(PreflightContext const& ctx)
{
// Check self-send
if (ctx.tx[sfAccount] == ctx.tx[sfDestination])
{
JLOG(ctx.j.warn()) << "Malformed transaction: Check to self.";
return temREDUNDANT;
}
// Validate SendMax
STAmount const sendMax{ctx.tx.getFieldAmount(sfSendMax)};
if (!isLegalNet(sendMax) || sendMax.signum() <= 0)
{
JLOG(ctx.j.warn()) << "Malformed transaction: bad sendMax amount";
return temBAD_AMOUNT;
}
// Validate currency
if (badCurrency() == sendMax.getCurrency())
{
JLOG(ctx.j.warn()) << "Malformed transaction: Bad currency.";
return temBAD_CURRENCY;
}
// Validate expiration if present
if (auto const optExpiry = ctx.tx[~sfExpiration])
{
if (*optExpiry == 0)
{
JLOG(ctx.j.warn()) << "Malformed transaction: bad expiration";
return temBAD_EXPIRATION;
}
}
return tesSUCCESS;
}
NotTEC, so no fees can be claimed for malformed transactionsWatch out. Preflight cannot return
teccodes because those codes claim the transaction fee, and preflight has no ledger view to charge anything against. It is stateless by design:preflight1checks the format,preflight2checks that the signature is cryptographically valid, and neither can touch an account.
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.
ReadView const& viewcheckSign confirms the (already cryptographically valid) signature comes from a key allowed to sign for this accounttec codes: Fee can be claimedThe same transactor, one phase later. Now it reads ledger state through ctx.view (read-only), and can return tec codes:
TER
CheckCreate::preclaim(PreclaimContext const& ctx)
{
// Check destination exists
AccountID const dstId{ctx.tx[sfDestination]};
auto const sleDst = ctx.view.read(keylet::account(dstId));
if (!sleDst)
{
JLOG(ctx.j.warn()) << "Destination account does not exist.";
return tecNO_DST;
}
auto const flags = sleDst->getFlags();
// Check incoming permission
if (ctx.view.rules().enabled(featureDisallowIncoming) &&
(flags & lsfDisallowIncomingCheck))
return tecNO_PERMISSION;
// Check pseudo-account
if (isPseudoAccount(sleDst))
return tecNO_PERMISSION;
// Check destination tag requirement
if ((flags & lsfRequireDestTag) && !ctx.tx.isFieldPresent(sfDestinationTag))
{
JLOG(ctx.j.warn()) << "Malformed transaction: DestinationTag required.";
return tecDST_TAG_NEEDED;
}
// Check freeze status for non-XRP amounts
STAmount const sendMax{ctx.tx[sfSendMax]};
if (!sendMax.native())
{
AccountID const& issuerId{sendMax.getIssuer()};
if (isGlobalFrozen(ctx.view, issuerId))
{
JLOG(ctx.j.warn()) << "Creating a check for frozen asset";
return tecFROZEN;
}
// Additional trust line freeze checks...
}
// Check expiration
if (hasExpired(ctx.view, ctx.tx[~sfExpiration]))
{
JLOG(ctx.j.warn()) << "Creating a check that has already expired.";
return tecEXPIRED;
}
return tesSUCCESS;
}
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 |
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.) |
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.
ApplyView& view()The final phase. Watch it check the reserve first, then create the Check entry and wire it into the owner directories:
TER
CheckCreate::doApply()
{
auto const sle = view().peek(keylet::account(accountID_));
if (!sle)
return tefINTERNAL;
// Check reserve before creating new object
STAmount const reserve{
view().fees().accountReserve(sle->getFieldU32(sfOwnerCount) + 1)};
if (preFeeBalance_ < reserve)
return tecINSUFFICIENT_RESERVE;
// Create the new ledger entry
std::uint32_t const seq = ctx_.tx.getSeqValue();
Keylet const checkKeylet = keylet::check(accountID_, seq);
auto sleCheck = std::make_shared<SLE>(checkKeylet);
// Set required fields
sleCheck->setAccountID(sfAccount, accountID_);
sleCheck->setAccountID(sfDestination, ctx_.tx[sfDestination]);
sleCheck->setFieldU32(sfSequence, seq);
sleCheck->setFieldAmount(sfSendMax, ctx_.tx[sfSendMax]);
// Set optional fields
if (auto const srcTag = ctx_.tx[~sfSourceTag])
sleCheck->setFieldU32(sfSourceTag, *srcTag);
if (auto const dstTag = ctx_.tx[~sfDestinationTag])
sleCheck->setFieldU32(sfDestinationTag, *dstTag);
if (auto const invoiceId = ctx_.tx[~sfInvoiceID])
sleCheck->setFieldH256(sfInvoiceID, *invoiceId);
if (auto const expiry = ctx_.tx[~sfExpiration])
sleCheck->setFieldU32(sfExpiration, *expiry);
// Insert into ledger
view().insert(sleCheck);
// Add to owner directories
auto const page = view().dirInsert(
keylet::ownerDir(accountID_),
checkKeylet,
describeOwnerDir(accountID_));
if (!page)
return tecDIR_FULL;
sleCheck->setFieldU64(sfOwnerNode, *page);
// Update owner count
adjustOwnerCount(view(), sle, 1, ctx_.app.journal("View"));
return tesSUCCESS;
}
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 |
In brief: the engine, not your transactor, charges the fee, bumps the sequence, and commits (or reverts) the result.
After doApply succeeds (or fails with a tec code), the transaction is finalized:
tec)This phase is handled by the engine, not by individual transactors.
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 | 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* |
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:
rAlice != rBob → Pass100 XRP is positive and legal → Pass750000000 != 0 → PasstesSUCCESSPreclaim:
lsfDisallowIncomingCheck → Not setisPseudoAccount(Bob) → FalselsfRequireDestTag → Not set (or tag provided)isGlobalFrozen(XRP) → XRP can't be frozen, skiphasExpired(750000000) → Not expiredtesSUCCESSdoApply:
preFeeBalance_ >= reserve → PasstesSUCCESSFinalization:
In brief: the habits that keep a transactor correct, cheap, and safe.
tem* for format errors, tec* for state-dependent failurespreFeeBalance_ for reserve checksJLOG to help with debuggingWhere 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 |
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:
NotTEC (no tec possible)checkSign verifies signing authority (master/regular/signer list); can return tectes and tec outcomessrc/libxrpl/tx/applySteps.cpp; shared phase logic: src/libxrpl/tx/Transactor.cpptec from preflight would let attackers drain fees with unsigned junk; that is exactly why NotTEC existsNext up. The gates are clear; now look at what every passage costs. Next: state modification, fees and sequences, the bookkeeping that no transaction escapes.
Resources
Assignments
0 of 2 complete