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)
{
if (ctx.tx[sfAccount] == ctx.tx[sfDestination])
{
// They wrote a check to themselves.
JLOG(ctx.j.warn()) << "Malformed transaction: Check to self.";
return temREDUNDANT;
}
{
STAmount const sendMax{ctx.tx.getFieldAmount(sfSendMax)};
if (!isLegalNet(sendMax) || sendMax.signum() <= 0)
{
JLOG(ctx.j.warn()) << "Malformed transaction: bad sendMax amount: "
<< sendMax.getFullText();
return temBAD_AMOUNT;
}
if (badAsset() == sendMax.asset())
{
JLOG(ctx.j.warn()) << "Malformed transaction: Bad currency.";
return temBAD_CURRENCY;
}
}
if (auto const optExpiry = ctx.tx[~sfExpiration])
{
if (*optExpiry == 0)
{
JLOG(ctx.j.warn()) << "Malformed transaction: bad expiration";
return temBAD_EXPIRATION;
}
}
return tesSUCCESS;
}
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.
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 claimedterQUEUED) 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 itselfThe 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)
{
AccountID const dstId{ctx.tx[sfDestination]};
AccountID const srcId{ctx.tx[sfAccount]};
auto const sleDst = ctx.view.read(keylet::account(dstId));
if (!sleDst)
{
JLOG(ctx.j.warn()) << "Destination account does not exist.";
return tecNO_DST;
}
// Check if the destination has disallowed incoming checks
if (sleDst->isFlag(lsfDisallowIncomingCheck))
return tecNO_PERMISSION;
// Pseudo-accounts cannot cash checks. Note, this is not amendment-gated
// because all writes to pseudo-account discriminator fields **are**
// amendment gated, hence the behaviour of this check will always match the
// currently active amendments.
if (isPseudoAccount(sleDst))
return tecNO_PERMISSION;
if (sleDst->isFlag(lsfRequireDestTag) && !ctx.tx.isFieldPresent(sfDestinationTag))
{
// The tag is basically account-specific information we don't
// understand, but we can require someone to fill it in.
JLOG(ctx.j.warn()) << "Malformed transaction: DestinationTag required.";
return tecDST_TAG_NEEDED;
}
{
STAmount const sendMax{ctx.tx[sfSendMax]};
if (!sendMax.native())
{
// The currency may not be globally frozen
AccountID const& issuerId{sendMax.getIssuer()};
if (auto const ter = checkGlobalFrozen(ctx.view, sendMax.asset()); !isTesSuccess(ter))
{
JLOG(ctx.j.warn()) << "Creating a check for frozen or locked asset";
return ter;
}
// ...per-trustline freeze checks (IOUs) and per-MPToken lock and
// transferability checks (MPTs) elided...
}
}
if (hasExpired(ctx.view, ctx.tx[~sfExpiration]))
{
JLOG(ctx.j.warn()) << "Creating a check that has already expired.";
return tecEXPIRED;
}
return tesSUCCESS;
}
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.
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.) |
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 destination's and the owner's directories:
TER
CheckCreate::doApply()
{
auto const sle = view().peek(keylet::account(accountID_));
if (!sle)
return tefINTERNAL;
// A check counts against the reserve of the issuing account, but we
// check the starting balance because we want to allow dipping into the
// reserve to pay fees.
{
STAmount const reserve{view().fees().accountReserve(sle->getFieldU32(sfOwnerCount) + 1)};
if (preFeeBalance_ < reserve)
return tecINSUFFICIENT_RESERVE;
}
// Note that we use the value from the sequence or ticket as the
// Check sequence. For more explanation see comments in SeqProxy.h.
std::uint32_t const seq = ctx_.tx.getSeqValue();
Keylet const checkKeylet = keylet::check(accountID_, seq);
auto sleCheck = std::make_shared<SLE>(checkKeylet);
sleCheck->setAccountID(sfAccount, accountID_);
AccountID const dstAccountId = ctx_.tx[sfDestination];
sleCheck->setAccountID(sfDestination, dstAccountId);
sleCheck->setFieldU32(sfSequence, seq);
sleCheck->setFieldAmount(sfSendMax, ctx_.tx[sfSendMax]);
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);
view().insert(sleCheck);
auto viewJ = ctx_.registry.get().getJournal("View");
// If it's not a self-send (and it shouldn't be), add Check to the
// destination's owner directory.
if (dstAccountId != accountID_)
{
auto const page = view().dirInsert(
keylet::ownerDir(dstAccountId), checkKeylet, describeOwnerDir(dstAccountId));
JLOG(j_.trace()) << "Adding Check to destination directory " << to_string(checkKeylet.key)
<< ": " << (page ? "success" : "failure");
if (!page)
return tecDIR_FULL;
sleCheck->setFieldU64(sfDestinationNode, *page);
}
{
auto const page = view().dirInsert(
keylet::ownerDir(accountID_), checkKeylet, describeOwnerDir(accountID_));
JLOG(j_.trace()) << "Adding Check to owner directory " << to_string(checkKeylet.key) << ": "
<< (page ? "success" : "failure");
if (!page)
return tecDIR_FULL;
sleCheck->setFieldU64(sfOwnerNode, *page);
}
// If we succeeded, the new entry counts against the creator's reserve.
adjustOwnerCount(view(), sle, 1, viewJ);
return tesSUCCESS;
}
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.
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, 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():
TER
Transactor::apply()
{
preCompute();
// If the transactor requires a valid account and the transaction doesn't
// list one, preflight will have already a flagged a failure.
auto const sle = view().peek(keylet::account(accountID_));
// sle must exist except for transactions
// that allow zero account.
XRPL_ASSERT(
sle != nullptr || accountID_ == beast::kZero,
"xrpl::Transactor::apply : non-null SLE or zero account");
if (sle)
{
preFeeBalance_ = STAmount{(*sle)[sfBalance]}.xrp();
TER result = consumeSeqProxy(sle);
if (!isTesSuccess(result))
return result;
result = payFee();
if (!isTesSuccess(result))
return result;
if (sle->isFieldPresent(sfAccountTxnID))
sle->setFieldH256(sfAccountTxnID, ctx_.tx.getTransactionID());
view().update(sle);
}
return 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:
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 sequencectx_.apply(result) records the changes in transaction metadatactx_.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.
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* |
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)checkGlobalFrozen → SendMax is XRP (native), check skippedhasExpired(750000000) → Not expiredtesSUCCESSBefore doApply (in Transactor::apply()):
preFeeBalance_consumeSeqProxy)payFee)doApply:
preFeeBalance_ >= reserve → PasssfDestinationNode)sfOwnerNode)tesSUCCESSFinalization:
tec or invariant failure here would trigger reset(fee): discard everything, re-apply fee and sequence)ctx_.apply(result))ctx_.destroyXRP(fee))In brief: the habits that keep a transactor correct, cheap, and safe.
tem* for format errors, tec* for state-dependent failurespreFeeBalance_ (the balance captured before payFee() ran) so accounts can dip into the reserve to pay feesJLOG 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 tecTransactor::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 sequencetes 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