The `Transactor` base class and framework — contexts, phases, and how every transaction type extends a shared base.
What you'll learn
≈90 min · Advanced · builds on Caching & resource management
Watch this short video by XRPL Commons first, then dive into the details below.
You've seen a transaction's journey from the outside, now let's meet the code that carries it out. In this module you'll learn the Transactor base class that every transaction type extends, the contexts that back each phase (PreflightContext, PreclaimContext, ApplyContext), and the shape of the transactor hierarchy. It's the framework you'll be building inside when you write your own transaction type later.
In brief: the shared base that every transaction type extends, holding common state and helpers.
Every transaction type in Rippled inherits from the Transactor base class, which provides the fundamental framework for transaction processing. This inheritance model ensures consistent behavior across all transaction types while allowing each type to implement its specific business logic.
The base Transactor class is defined in include/xrpl/tx/Transactor.h and provides:
class Transactor
{
public:
// Main entry point for transaction application
static std::pair<TER, bool>
apply(Application& app, OpenView& view, STTx const& tx, ApplyFlags flags);
// Virtual methods for transaction-specific logic
static NotTEC preflight(PreflightContext const& ctx);
static TER preclaim(PreclaimContext const& ctx);
virtual TER doApply() = 0;
protected:
// Constructor - available to derived classes
Transactor(ApplyContext& ctx);
// Helper methods
TER payFee();
TER checkSeq();
TER checkSign(PreclaimContext const& ctx);
// Member variables
ApplyContext& ctx_;
beast::Journal j_;
AccountID accountID_;
XRPAmount preFeeBalance_;
};
ApplyContext: Provides access to the transaction being processed, the ledger view, and application services. This context object is passed through all stages of transaction processing.
Transaction Engine Result (TER): Every validation step returns a TER code indicating success (tesSUCCESS), temporary failure (ter codes), or permanent failure (tem or tef codes). These codes determine whether a transaction can be retried or should be permanently rejected.
Ledger Views: Transactors work with "views" of the ledger state, allowing tentative modifications that can be committed or rolled back. This ensures atomic transaction processing.
Key idea. Every transaction type inherits from
Transactor. Learn the base once and you understand the skeleton of every transaction on the ledger.
In brief: preflight, preclaim and doApply, each with its own context object.
The transactor framework implements a rigorous three-phase validation process. Each phase has a specific purpose and access to different levels of information, creating a defense-in-depth approach to transaction validation.
Purpose: Static validation that doesn't require ledger state
Access: Only the raw transaction data and protocol rules
When It Runs: Before any ledger state is accessed, can run in parallel
What It Checks:
Key Characteristic: Preflight checks are deterministic and stateless, they depend only on the transaction itself, not on current ledger state.
Preflight Example: Payment Transaction
NotTEC Payment::preflight(PreflightContext const& ctx)
{
// Note: Payment itself has NO amendment gate (it predates amendments).
// Newer transactors start with one, e.g. in AMMCreate:
// if (!ctx.rules.enabled(featureAMM))
// return temDISABLED;
// Call base class preflight checks
auto const ret = preflight1(ctx);
if (!isTesSuccess(ret))
return ret;
// Verify destination account is specified
if (!ctx.tx.isFieldPresent(sfDestination))
return temDST_NEEDED;
// Verify amount is specified and valid
auto const amount = ctx.tx[sfAmount];
if (!amount)
return temBAD_AMOUNT;
// Amount must be positive
if (amount <= zero)
return temBAD_AMOUNT;
// Check for valid currency code if not XRP
if (!isXRP(amount))
{
if (!amount.issue().currency)
return temBAD_CURRENCY;
}
// Additional format validations...
return preflight2(ctx);
}
Why Preflight Matters: By catching format errors early, preflight prevents wasting resources on obviously invalid transactions. It also provides fast feedback to clients about transaction formatting issues.
Purpose: Validation requiring read-only access to ledger state
Access: Current ledger state (read-only), transaction data, protocol rules
When It Runs: After preflight passes, but before any state modifications
What It Checks:
Key Characteristic: Preclaim can read ledger state but cannot modify it. This allows for safe concurrent execution and caching of preclaim results.
Preclaim Example: Payment Transaction
TER Payment::preclaim(PreclaimContext const& ctx)
{
// Get source and destination account IDs
AccountID const src = ctx.tx[sfAccount];
AccountID const dst = ctx.tx[sfDestination];
// Source and destination cannot be the same
if (src == dst)
return temREDUNDANT;
// Check if destination account exists
auto const dstID = ctx.tx[sfDestination];
auto const sleDst = ctx.view.read(keylet::account(dstID));
// If destination doesn't exist, check if we can create it
if (!sleDst)
{
auto const amount = ctx.tx[sfAmount];
// Only XRP can create accounts
if (!isXRP(amount))
return tecNO_DST;
// Amount must meet reserve requirement
if (amount < ctx.view.fees().accountReserve(0))
return tecNO_DST_INSUF_XRP;
}
else
{
// Destination exists - check if it requires dest tag
auto const flags = sleDst->getFlags();
if (flags & lsfRequireDestTag)
{
// Destination requires a tag but none provided
if (!ctx.tx.isFieldPresent(sfDestinationTag))
return tecDST_TAG_NEEDED;
}
// Check if destination has disallowed XRP
if (flags & lsfDisallowXRP && isXRP(ctx.tx[sfAmount]))
return tecNO_TARGET;
}
// Check source account balance
auto const sleSrc = ctx.view.read(keylet::account(src));
if (!sleSrc)
return terNO_ACCOUNT;
auto const balance = (*sleSrc)[sfBalance];
auto const amount = ctx.tx[sfAmount];
// Ensure sufficient balance (including fee)
if (balance < amount + ctx.tx[sfFee])
return tecUNFUNDED_PAYMENT;
return tesSUCCESS;
}
Why Preclaim Matters: Preclaim catches state-dependent errors before attempting state modifications. This prevents partially-applied transactions and provides clear error messages about why a transaction cannot succeed.
Purpose: Actual ledger state modification
Access: Full read/write access to ledger state
When It Runs: After both preflight and preclaim succeed
What It Does:
Key Characteristic: DoApply modifies ledger state. All changes are atomic, either the entire transaction succeeds and all changes are applied, or it fails and no changes are made.
DoApply Example: Payment Transaction
The fee is not paid here: Transactor::apply() already called payFee() before invoking your doApply (see src/libxrpl/tx/Transactor.cpp). A derived transactor never touches the fee.
TER Payment::doApply()
{
// Get amount to send
auto const amount = ctx_.tx[sfAmount];
auto const dst = ctx_.tx[sfDestination];
// Perform the actual transfer
auto const transferResult = accountSend(
view(), // Ledger view to modify
accountID_, // Source account
dst, // Destination account
amount, // Amount to transfer
j_ // Journal for logging
);
if (transferResult != tesSUCCESS)
return transferResult;
// Handle partial payments and path finding if applicable
if (ctx_.tx.isFlag(tfPartialPayment))
{
// Partial payment logic...
}
// Record transaction metadata
ctx_.deliver(amount);
return tesSUCCESS;
}
Why DoApply Matters: This is where the actual ledger state changes happen. DoApply ensures that only transactions that have passed all validation steps can modify the ledger, maintaining data integrity.
In brief: how concrete transactors (Payment, OfferCreate, Escrow, and more) specialize the base.
The XRP Ledger supports numerous transaction types, each implemented as a specific transactor. Understanding the most common types helps you navigate the codebase and understand protocol capabilities.
File: src/libxrpl/tx/transactors/payment/Payment.cpp
Purpose: Transfer XRP or issued currencies between accounts
Key Features:
Common Fields:
Account - Source accountDestination - Recipient accountAmount - Amount to deliverSendMax (optional) - Maximum amount to sendPaths (optional) - Payment paths for currency conversionDestinationTag (optional) - Identifier for the recipientUse Cases:
File: src/libxrpl/tx/transactors/dex/OfferCreate.cpp
Purpose: Place an offer on the decentralized exchange (DEX)
Key Features:
Common Fields:
TakerPays - Asset the taker (matcher) paysTakerGets - Asset the taker receivesExpiration (optional) - When offer expiresOfferSequence (optional) - Sequence of offer to replaceUse Cases:
File: src/libxrpl/tx/transactors/dex/OfferCancel.cpp
Purpose: Remove an offer from the order book
Key Features:
Common Fields:
OfferSequence - Sequence number of offer to cancelFile: src/libxrpl/tx/transactors/token/TrustSet.cpp
Purpose: Create or modify a trust line for issued currencies
Key Features:
Common Fields:
LimitAmount - Trust line limit and currencyQualityIn (optional) - Exchange rate for incoming transfersQualityOut (optional) - Exchange rate for outgoing transfersUse Cases:
File: src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp
Purpose: Lock XRP until conditions are met
Key Features:
Common Fields:
Destination - Who can claim the escrowAmount - Amount of XRP to escrowFinishAfter (optional) - Earliest finish timeCancelAfter (optional) - When escrow can be cancelledCondition (optional) - Cryptographic condition for releaseFile: src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp
Purpose: Complete an escrow and deliver XRP
Key Features:
Common Fields:
Owner - Account that created the escrowOfferSequence - Sequence of EscrowCreate transactionFulfillment (optional) - Fulfillment of cryptographic conditionFile: src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp
Purpose: Return escrowed XRP to owner
Key Features:
File: src/libxrpl/tx/transactors/account/AccountSet.cpp
Purpose: Modify account settings and flags
Key Features:
Common Fields:
SetFlag / ClearFlag - Flags to modifyTransferRate (optional) - Fee for transferring issued currenciesDomain (optional) - Domain associated with accountMessageKey (optional) - Public key for encrypted messagingImportant Flags:
asfRequireDest - Require destination tagasfRequireAuth - Require authorization for trust linesasfDisallowXRP - Disallow XRP paymentsasfDefaultRipple - Enable rippling by defaultFile: src/libxrpl/tx/transactors/account/SignerListSet.cpp
Purpose: Create or modify multi-signature configuration
Key Features:
Common Fields:
SignerQuorum - Required signature weightSignerEntries - List of authorized signers with weightsFile: src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp
Purpose: Open a unidirectional payment channel
Key Features:
In brief: the steps to add a brand-new transaction type to the protocol.
When implementing new features through amendments, you'll often need to create custom transactors. Here's the complete process:
Add your transaction type to src/libxrpl/protocol/TxFormats.cpp:
add(jss::MyCustomTx,
ttMY_CUSTOM_TX,
{
// Required fields
{sfAccount, SoeRequired},
{sfDestination, SoeRequired},
{sfCustomField, SoeRequired},
// Optional fields
{sfOptionalField, SoeOptional},
},
commonFields);
Create include/xrpl/tx/transactors/MyCustomTx.h:
#ifndef RIPPLE_TX_MYCUSTOMTX_H_INCLUDED
#define RIPPLE_TX_MYCUSTOMTX_H_INCLUDED
#include <xrpl/tx/Transactor.h>
namespace xrpl {
class MyCustomTx : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Normal};
explicit MyCustomTx(ApplyContext& ctx) : Transactor(ctx) {}
static NotTEC preflight(PreflightContext const& ctx);
static TER preclaim(PreclaimContext const& ctx);
TER doApply() override;
};
} // namespace xrpl
#endif
Create src/libxrpl/tx/transactors/MyCustomTx.cpp:
#include <xrpl/tx/transactors/MyCustomTx.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Feature.h>
namespace xrpl {
NotTEC MyCustomTx::preflight(PreflightContext const& ctx)
{
// Check if amendment is enabled
if (!ctx.rules.enabled(featureMyCustomTx))
return temDISABLED;
// Perform base class preflight checks
auto const ret = preflight1(ctx);
if (!isTesSuccess(ret))
return ret;
// Validate custom field format
if (!ctx.tx.isFieldPresent(sfCustomField))
return temMALFORMED;
auto const customValue = ctx.tx[sfCustomField];
if (customValue < 0 || customValue > 1000000)
return temBAD_AMOUNT;
// Additional validation...
return preflight2(ctx);
}
TER MyCustomTx::preclaim(PreclaimContext const& ctx)
{
// Get account IDs
AccountID const src = ctx.tx[sfAccount];
AccountID const dst = ctx.tx[sfDestination];
// Verify destination account exists
auto const sleDst = ctx.view.read(keylet::account(dst));
if (!sleDst)
return tecNO_DST;
// Check source account has sufficient balance
auto const sleSrc = ctx.view.read(keylet::account(src));
if (!sleSrc)
return terNO_ACCOUNT;
auto const balance = (*sleSrc)[sfBalance];
auto const fee = ctx.tx[sfFee];
if (balance < fee)
return tecUNFUNDED;
// Additional state-based validation...
return tesSUCCESS;
}
TER MyCustomTx::doApply()
{
// (fee already charged by Transactor::apply() before we get here)
// Get transaction fields
auto const dst = ctx_.tx[sfDestination];
auto const customValue = ctx_.tx[sfCustomField];
// Perform custom logic
// Example: Create a new ledger object
auto const sleNew = std::make_shared<SLE>(
keylet::custom(accountID_, ctx_.tx.getSeqProxy().value()));
sleNew->setAccountID(sfAccount, accountID_);
sleNew->setAccountID(sfDestination, dst);
sleNew->setFieldU32(sfCustomField, customValue);
// Insert into ledger
view().insert(sleNew);
// Log the operation
JLOG(j_.trace()) << "MyCustomTx applied successfully";
return tesSUCCESS;
}
} // namespace xrpl
Add to src/libxrpl/tx/applySteps.cpp:
#include <xrpl/tx/transactors/MyCustomTx.h>
// In the invoke function, add:
case ttMY_CUSTOM_TX:
return MyCustomTx::makeTxConsequences(ctx);
Create src/test/app/MyCustomTx_test.cpp:
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/jss.h>
#include <test/jtx.h>
namespace xrpl {
namespace test {
class MyCustomTx_test : public beast::unit_test::suite
{
public:
void testBasicOperation()
{
using namespace jtx;
Env env(*this, supported_amendments() | featureMyCustomTx);
// Create test accounts
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(10000), alice, bob);
// Submit custom transaction
Json::Value jv;
jv[jss::Account] = alice.human();
jv[jss::Destination] = bob.human();
jv[jss::TransactionType] = jss::MyCustomTx;
jv[jss::CustomField] = 12345;
jv[jss::Fee] = "10";
env(jv);
env.close();
// Verify results
// Add assertions...
}
void run() override
{
testBasicOperation();
// More tests...
}
};
BEAST_DEFINE_TESTSUITE(MyCustomTx, app, xrpl);
} // namespace test
} // namespace xrpl
Understanding how a transaction flows through the transactor framework helps debug issues and optimize performance.
tem (Malformed): Transaction is permanently invalid due to format issues
temMALFORMED, temBAD_AMOUNT, temDISABLEDtef (Failure): Transaction failed during local checks
tefFAILURE, tefPAST_SEQter (Retry): Transaction failed but might succeed later
terQUEUED, terPRE_SEQtec (Claimed Fee): Transaction failed but consumed fee
tecUNFUNDED, tecNO_DST, tecNO_PERMISSIONtes (Success): Transaction succeeded
tesSUCCESSxrpld submit '{ "TransactionType": "AccountSet", "Account": "<address2>", "SetFlag": 1, "Fee": "12" }'
**Step 4**: Try payment without destination tag
```bash
# This should fail with tecDST_TAG_NEEDED
xrpld submit '{
"TransactionType": "Payment",
"Account": "<address1>",
"Destination": "<address2>",
"Amount": "1000000",
"Fee": "12"
}'
Step 5: Try payment with destination tag
# This should succeed
xrpld submit '{
"TransactionType": "Payment",
"Account": "<address1>",
"Destination": "<address2>",
"Amount": "1000000",
"DestinationTag": 12345,
"Fee": "12"
}'
Part 3: Modify the Transactor (Advanced)
Step 1: Add custom logging
Edit Payment.cpp and add logging to doApply():
TER Payment::doApply()
{
JLOG(j_.info()) << "Payment doApply started";
JLOG(j_.info()) << "Source: " << accountID_;
JLOG(j_.info()) << "Destination: " << ctx_.tx[sfDestination];
JLOG(j_.info()) << "Amount: " << ctx_.tx[sfAmount];
// ... existing code ...
}
Step 2: Recompile rippled
cd rippled/build
cmake --build . --target xrpld
Step 3: Run with your modified code
./xrpld --conf=xrpld.cfg --standalone
Step 4: Submit a payment and observe your logs
Analysis Questions
Answer these based on your exploration:
payFee() called?accountSend() helper?src/libxrpl/tx/transactors - All transactor implementationsinclude/xrpl/tx/Transactor.h - Base transactor classsrc/libxrpl/protocol/TxFormats.cpp - Transaction format definitionsinclude/xrpl/protocol/TER.h - Transaction result codesEvery transaction that modifies the XRP Ledger, whether it's a payment, an offer, a trust line, or any other operation, is processed by a Transactor. The transactor architecture provides a consistent, safe, and extensible framework for implementing transaction types while ensuring that the ledger remains in a valid state.
Understanding this architecture is essential for anyone who wants to implement new transaction types, debug validation failures, or contribute to the rippled codebase. This section explores the layered design of the transaction engine and how each component contributes to the safety and correctness of ledger modifications.
The Transactor class, defined in include/xrpl/tx/Transactor.h, is the foundation of all transaction processing in rippled. Every transaction type inherits from this base class, which provides:
class Transactor
{
protected:
ApplyContext& ctx_;
beast::WrappedSink sink_;
beast::Journal const j_;
AccountID const accountID_;
XRPAmount preFeeBalance_{}; // Balance before fees.
public:
enum class ConsequencesFactoryType { Normal, Blocker, Custom };
// Main entry point for transaction application
ApplyResult operator()();
ApplyView& view();
ApplyView const& view() const;
// Static methods for validation phases
static NotTEC checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j);
static NotTEC checkPriorTxAndLastLedger(PreclaimContext const& ctx);
static TER checkFee(PreclaimContext const& ctx, XRPAmount baseFee);
static NotTEC checkSign(PreclaimContext const& ctx);
static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx);
// Default implementations that derived classes can override
static TER preclaim(PreclaimContext const& ctx) { return tesSUCCESS; }
protected:
TER apply();
explicit Transactor(ApplyContext& ctx);
virtual void preCompute();
virtual TER doApply() = 0; // Pure virtual - must be implemented
};
Key observations:
doApply() is pure virtual: Every transaction type must implement this methodpreclaim() has a default implementation: Returns tesSUCCESS, can be overriddenpreFeeBalance_: Tracks the account's XRP balance before fees are deductedctx_: The ApplyContext provides access to the transaction, view, and applicationThe transactor framework uses three context objects that provide different levels of access at each processing phase:
Used during the preflight phase for stateless validation:
struct PreflightContext
{
Application& app;
STTx const& tx;
Rules const rules;
ApplyFlags flags;
beast::Journal const j;
};
What it provides:
tx)rules)flags)j)What it does NOT provide:
view)This limitation is intentional, preflight checks must be stateless and deterministic based solely on the transaction content.
Used during the preclaim phase for read-only ledger validation:
struct PreclaimContext
{
Application& app;
ReadView const& view;
TER preflightResult;
ApplyFlags flags;
STTx const& tx;
beast::Journal const j;
};
What it provides:
view)preflightResult)Key distinction: The view is ReadView const&, you can read ledger state but cannot modify it.
Used during the doApply phase for ledger modification:
class ApplyContext
{
public:
Application& app;
ApplyView& view();
STTx const& tx;
beast::Journal journal;
// ... additional methods
};
What it provides:
view()New transaction types must be registered with the transaction engine. This is done through a combination of:
src/libxrpl/protocol/TxFormats.cppsrc/libxrpl/tx/transactorsapplySteps.cppIn TxFormats.cpp:
add(jss::CheckCreate,
ttCHECK_CREATE,
{
{sfDestination, SoeRequired},
{sfSendMax, SoeRequired},
{sfExpiration, SoeOptional},
{sfDestinationTag, SoeOptional},
{sfInvoiceID, SoeOptional},
},
commonFields);
This defines:
jss::CheckCreate)ttCHECK_CREATE)All specific transaction types inherit from Transactor and implement their own validation logic:
Each derived class typically implements:
preflight(): Static method for stateless validationpreclaim(): Static method for ledger-state validationdoApply(): Instance method for applying changesThe base class provides a template method that orchestrates the preflight phase:
template <class T>
NotTEC
Transactor::invokePreflight(PreflightContext const& ctx)
{
// 1. Check if the transaction type's feature is enabled
auto const feature =
Permission::getInstance().getTxFeature(ctx.tx.getTxnType());
if (feature && !ctx.rules.enabled(*feature))
return temDISABLED;
// 2. Check any extra features the transaction requires
if (!T::checkExtraFeatures(ctx))
return temDISABLED;
// 3. Run preflight1 (account, fee, flags validation)
if (auto const ret = preflight1(ctx, T::getFlagsMask(ctx)))
return ret;
// 4. Run the transaction-specific preflight
if (auto const ret = T::preflight(ctx))
return ret;
// 5. Run preflight2 (signature validation)
if (auto const ret = preflight2(ctx))
return ret;
// 6. Run any post-signature validation
return T::preflightSigValidated(ctx);
}
This template ensures consistent ordering of validation steps across all transaction types.
The AccountID of the transaction sender, extracted from the sfAccount field:
AccountID const accountID_;
This is set during construction and used throughout transaction processing.
XRPAmount preFeeBalance_{}; // Balance before fees
This records the sender's XRP balance at the start of transaction processing, before the
fee is deducted. It is used for reserve calculations, the reserve is checked against
preFeeBalance_ to allow accounts to dip into their reserve to pay the transaction fee. The
fee is charged, and the post-fee balance written to the ledger, by the base class itself.
ApplyContext& ctx_;
The ApplyContext provides access to:
ctx_.tx: The transaction being processedctx_.view(): The ledger view for reading/writingctx_.app: The application instancectx_.journal: LoggingThe base class provides several helper methods used by derived transactors:
ApplyView& view() { return ctx_.view(); }
Returns the ledger view for reading and modifying ledger state.
static XRPAmount calculateBaseFee(ReadView const& view, STTx const& tx);
Calculates the base transaction fee based on the transaction type and current fee settings.
static XRPAmount minimumFee(
Application& app,
XRPAmount baseFee,
Fees const& fees,
ApplyFlags flags);
Calculates the minimum fee required considering current load and fee escalation.
| File | Description |
|---|---|
include/xrpl/tx/Transactor.h |
Base Transactor class definition |
src/libxrpl/tx/Transactor.cpp |
Base class implementation |
include/xrpl/tx/ApplyContext.h |
ApplyContext definition |
src/libxrpl/tx/applySteps.cpp |
Transaction type dispatch |
src/libxrpl/protocol/TxFormats.cpp |
Transaction format definitions |
This module introduced the transactor framework. Every transaction type derives from the shared Transactor base class and runs the same three phases, each with its own context: PreflightContext, PreclaimContext, and ApplyContext. You saw how concrete transactors (Payment, OfferCreate, Escrow, and others) specialise the base, the role of members like preFeeBalance_, and the steps to add a brand-new transaction type.
To remember:
Transactor (include/xrpl/tx/Transactor.h)preflight(PreflightContext) and preclaim(PreclaimContext); member doApply() with ApplyContext ctx_accountID_, preFeeBalance_ (reserve checks), view()ConsequencesFactoryType { Normal, Blocker, Custom } declares how the tx behaves in the queuesrc/libxrpl/tx/transactors/<family>/doApply may writeNext up. You met the family; now watch one member run the gauntlet. Next: the processing pipeline, preflight to doApply, and why each gate exists.
Resources
Assignments
0 of 2 complete