7.8 KiB
Transactors
Transaction processing pipeline: preflight (static validation) -> preclaim (ledger state checks) -> doApply (state mutation). Base class Transactor in src/libxrpl/tx/.
Key Invariants
- Pipeline is strict: preflight runs WITHOUT ledger state, preclaim runs WITH read-only view, doApply runs with mutable view
preflightvalidates all fields exist and are well-formed; this is the ONLY place to reject malformed transactions cheaply- Fee is always deducted even if the transaction fails (
tecCLAIMpattern);payFeeruns beforedoApply - Sequence/ticket consumption happens in
consumeSeqProxy; must succeed before any state changes - Invariant checkers run after
doApply; they can veto the transaction post-execution
Common Bug Patterns
- New transaction type missing preflight validation for new fields = malformed transactions reach doApply and corrupt state
- Forgetting to handle
tecCLAIMin doApply: fee is deducted but no other state changes should occur - Batch transactions (
Batchtype) have their own signing path (checkBatchSign); changes to signing must cover both paths calculateBaseFeeoverride without updatingminimumFeecauses fee calculation divergence between nodes- Missing invariant checker update for new ledger entry types = silent constraint violations
Transactor Template
Header (include/xrpl/tx/transactors/MyTx.h)
#pragma once
#include <xrpl/tx/Transactor.h>
namespace xrpl {
class MyTransaction : public Transactor {
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Normal};
explicit MyTransaction(ApplyContext& ctx) : Transactor(ctx) {}
static bool checkExtraFeatures(PreflightContext const& ctx);
static std::uint32_t getFlagsMask(PreflightContext const& ctx);
static NotTEC preflight(PreflightContext const& ctx); // NO ledger
static TER preclaim(PreclaimContext const& ctx); // read-only
TER doApply() override; // read-write
};
}
Implementation (src/libxrpl/tx/transactors/MyFeature/MyTx.cpp)
bool MyTransaction::checkExtraFeatures(PreflightContext const& ctx)
{ // REQUIRED: gate on amendment
return ctx.rules.enabled(featureMyFeature);
}
NotTEC MyTransaction::preflight(PreflightContext const& ctx)
{ // Static validation — NO ctx.view, NO ledger access
if (ctx.tx[sfAmount] <= beast::zero)
return temBAD_AMOUNT;
return tesSUCCESS;
}
TER MyTransaction::preclaim(PreclaimContext const& ctx)
{ // Read-only — ctx.view.read() only, NO peek/insert/erase
if (!ctx.view.exists(keylet::account(ctx.tx[sfAccount])))
return terNO_ACCOUNT;
return tesSUCCESS;
}
TER MyTransaction::doApply()
{ // Mutable — view().peek(), view().insert(), view().update(), view().erase()
auto sle = view().peek(keylet::account(account_));
sle->setFieldAmount(sfBalance, newBal);
view().update(sle); // REQUIRED after mutation
return tesSUCCESS;
}
Registration Checklist
// ALL of these are REQUIRED for a new transaction type:
// 1. transactions.macro: TRANSACTION(ttMY_TYPE, N, MyTx, delegation, fields)
// 2. applySteps.cpp: case ttMY_TYPE: return invoke<MyTransaction>(...);
// 3. features.macro: XRPL_FEATURE(MyFeature, Supported::yes, DefaultNo)
// 4. Feature.h: increment numFeatures
// 5. InvariantCheck.cpp: update if new ledger objects created
// 6. Batch.cpp: add to disabledTxTypes if not batch-compatible
Transaction Lifecycle
preflight(static checks, no ledger) ->PreflightResultpreclaim(ledger state, read-only) -> TERoperator()orchestrates:checkSeqProxy->checkPriorTxAndLastLedger->checkFee->checkSign->applyTransactor::apply()runsconsumeSeqProxy->payFee->doApplyand returns a TERoperator()inspects the TER, decides whether to commit (ctx_.apply) or discard (ctx_.discard/reset)
State Commitment & tec* Rollback (CRITICAL for review)
doApply mutations are NOT committed until ctx_.apply() is called at the end of operator(). All peek/insert/update/erase during doApply go into an ApplyContext view (view_) layered on top of base_. Whether that view gets flushed to base_ depends entirely on the TER that doApply returns.
ApplyContext::discard() (src/libxrpl/tx/ApplyContext.cpp) replaces view_ with a fresh view on base_ — every doApply mutation is thrown away:
void ApplyContext::discard() { view_.emplace(&base_, flags_); }
Return-code decision table (in Transactor::operator(), src/libxrpl/tx/Transactor.cpp)
| doApply returns | What commits to the ledger |
|---|---|
tesSUCCESS |
All doApply mutations + fee + seq (via ctx_.apply) |
tec* (normal, !tapRETRY) |
reset(fee) calls discard(), then re-applies fee + seq only. All doApply mutations reverted. |
tec* with tapFAIL_HARD |
discard() called directly, nothing committed (not even fee) |
tec* with tapRETRY |
applied=false, ctx_.apply never called, tx re-queued |
tef* / tem* / ter* |
applied=false, ctx_.apply never called |
tecINVARIANT_FAILED after invariants |
reset again, commit fee only |
isTecClaimHardFail(ter, flags) = isTecClaim(ter) && !(flags & tapRETRY) (include/xrpl/tx/applySteps.h) — this predicate is what drives the reset path for normal consensus application.
What this means for transactor authors and reviewers
- A
tec*return from doApply acts as a full-transaction rollback. You do NOT need to order mutations defensively so that all checks come before any state changes. If a helper called late in doApply returnstec*, everything mutated earlier in the same doApply is discarded viadiscard(). - Orphan-state bugs of the form "we mutated X then returned tec so X is now in an inconsistent state" are not possible at the transactor boundary.* The ApplyContext isolates the whole doApply as an atomic unit.
- The real failure mode is within
doApplyitself: if you callview().update(sle)on a stale SLE pointer, or mutate a variable you read by value instead of peek, those are real bugs — but they are in-memory bugs, not state-commit bugs. - Sandboxes inside
doApplyadd nesting, not safety.PaymentSandbox/ nestedApplyVieware useful when you need to conditionally commit a subset of changes within a single doApply (e.g., apply offers but revert if the net outcome fails). They are not needed to protect against doApply's owntec*return — that rollback is automatic. - Only
ctx_.apply(result)publishes tobase_; a doApply thatreturns early, throws, or crashes never reaches that call, so base_ stays clean.
Verifying a suspected orphan-state bug
Before claiming "directory removed but SLE not erased because tec*":
- Read the caller of
doApply— confirm the TER path (operator()in Transactor.cpp). - Check whether
discard()is reached viareset()or thetapFAIL_HARDbranch. - If both paths call
discard(), the mutations cannot persist on tec*. - Look instead for: missing
view().update(sle)after mutation, stale SLE pointers, or genuine non-atomic side effects (e.g., hash router flags, which are NOT in the ApplyContext view).
Permission System
checkSigndispatches tocheckSingleSign,checkMultiSign, orcheckBatchSigncheckPermissionvalidates delegated authority for delegatable transaction types- Multi-sign requires M-of-N signers matching the signer list; weight threshold must be met
Key Files
src/xrpld/app/tx/detail/Transactor.cpp- base class and pipelineinclude/xrpl/protocol/detail/transactions.macro- type definitionssrc/xrpld/app/tx/detail/- per-type implementations (Payment.cpp, OfferCreate.cpp, etc.)src/xrpld/app/tx/detail/InvariantCheck.cpp- post-execution invariant checks