How a transactor modifies ledger state through views, and how fees, reserves and sequence numbers are handled.
What you'll learn
≈60 min · Advanced · builds on The transaction processing pipeline
Once a transaction is cleared to run, how does it actually change the ledger? In this module you'll learn to modify ledger entries through views and keylets, and to handle the three things every transactor must get right: fees, reserves (via preFeeBalance_), and sequence numbers, including tickets and SeqProxy. This is the toolkit doApply is built from.
In brief: ReadView, ApplyView and OpenView, the read-only and read/write windows onto ledger state.
The view system provides three levels of access:
Read-only access to ledger state. Used in preclaim:
class ReadView
{
public:
// Read a ledger entry (returns nullptr if not found)
virtual std::shared_ptr<SLE const> read(Keylet const& k) const = 0;
// Check if an entry exists
virtual bool exists(Keylet const& k) const = 0;
// Get current fees
virtual Fees const& fees() const = 0;
// Get current rules (amendments)
virtual Rules const& rules() const = 0;
// Get ledger sequence
virtual LedgerIndex seq() const = 0;
};
Example usage in preclaim:
TER CheckCreate::preclaim(PreclaimContext const& ctx)
{
// Read destination account (read-only)
auto const sleDst = ctx.view.read(keylet::account(dstId));
if (!sleDst)
return tecNO_DST;
// Read flags
auto const flags = sleDst->getFlags();
// ...
}
Read/write access to ledger state. Used in doApply:
class ApplyView : public ReadView
{
public:
// Get modifiable reference to an entry
virtual SLE::pointer peek(Keylet const& k) = 0;
// Insert a new entry
virtual void insert(SLE::ref sle) = 0;
// Indicate changes to a peeked SLE
virtual void update(SLE::ref sle) = 0;
// Remove a peeked SLE
virtual void erase(SLE::ref sle) = 0;
// Directory operations. These are non-virtual convenience
// members that wrap the private dirAdd/dirRemove machinery.
std::optional<std::uint64_t> dirInsert(
Keylet const& directory,
Keylet const& key,
std::function<void(SLE::ref)> const& describe);
bool dirRemove(
Keylet const& directory,
std::uint64_t page,
uint256 const& key,
bool keepRoot);
};
SLE::pointer is std::shared_ptr<STLedgerEntry> and SLE::ref is std::shared_ptr<STLedgerEntry> const& (include/xrpl/protocol/STLedgerEntry.h).
The writable view representing the open ledger. It accumulates state and transaction changes as incoming transactions are applied against it.
In brief: the objects you create, read, modify and delete in the ledger.
Ledger entries are represented as SLE objects (Serialized Ledger Entries). Each SLE has:
// Create a new Check entry
Keylet const checkKeylet = keylet::check(accountID_, seq);
auto sleCheck = std::make_shared<SLE>(checkKeylet);
// Set required fields
sleCheck->setAccountID(sfAccount, accountID_);
sleCheck->setAccountID(sfDestination, dstAccountId);
sleCheck->setFieldU32(sfSequence, seq);
sleCheck->setFieldAmount(sfSendMax, ctx_.tx[sfSendMax]);
// Set optional fields
if (auto const srcTag = ctx_.tx[~sfSourceTag])
sleCheck->setFieldU32(sfSourceTag, *srcTag);
// Read-only (in preclaim)
auto const sle = ctx.view.read(keylet::account(accountId));
if (!sle)
return tecNO_ENTRY;
// Get field values
auto const flags = sle->getFlags();
auto const balance = sle->getFieldAmount(sfBalance);
auto const sequence = sle->getFieldU32(sfSequence);
// Get modifiable reference (in doApply)
auto sle = view().peek(keylet::account(accountID_));
if (!sle)
return tefINTERNAL;
// Modify fields
sle->setFieldU32(sfSequence, newSequence);
sle->setFieldAmount(sfBalance, newBalance);
// Mark as updated — REQUIRED for the change to take effect
view().update(sle);
Watch out.
view().update()is not optional. A peeked SLE is tracked asAction::Cachein theApplyStateTable, and cached items are skipped both when the state table is applied and when transaction metadata is built (src/libxrpl/ledger/ApplyStateTable.cpp). Onlyupdate()promotes the entry toAction::Modify. Mutating a peeked SLE without callingupdate()silently drops the change.
// Get the entry
auto sle = view().peek(keylet::check(owner, seq));
if (!sle)
return tecNO_ENTRY;
// Remove from directories first
view().dirRemove(
keylet::ownerDir(owner),
sle->getFieldU64(sfOwnerNode),
sle->key(),
true); // keepRoot: keep the owner directory's root page
// Decrement owner count
adjustOwnerCount(view(), sleAccount, -1, j);
// Delete the entry
view().erase(sle);
In brief: the helper that computes the exact key locating any ledger entry.
Keylets are typed wrappers around ledger entry keys. They combine a type and a key, ensuring type safety when accessing ledger entries.
// Common keylet functions
keylet::account(AccountID const& id); // Account root
keylet::check(AccountID const& id, std::uint32_t seq); // Check
keylet::offer(AccountID const& id, std::uint32_t seq); // Offer
keylet::trustLine(AccountID const& a, AccountID const& b, Currency const& c); // Trust line
keylet::ownerDir(AccountID const& id); // Owner directory
keylet::escrow(AccountID const& src, std::uint32_t seq); // Escrow
keylet::payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq); // Payment channel
Example:
// Create a keylet for a check
Keylet const checkKeylet = keylet::check(accountID_, seq);
// Use it to create or access the entry
auto sleCheck = std::make_shared<SLE>(checkKeylet);
// or
auto sleCheck = view().peek(checkKeylet);
Directories are linked lists of ledger entry keys, used to track which objects an account owns. Every account has an owner directory that lists all objects owned by that account.
When creating a new ledger object, add it to the appropriate directories:
// Add to owner's directory
auto const page = view().dirInsert(
keylet::ownerDir(accountID_), // Directory to add to
sleCheck->key(), // Key of the new entry
describeOwnerDir(accountID_)); // Description callback
if (!page)
return tecDIR_FULL; // Directory has too many entries
// Store the page number in the entry for later removal
sleCheck->setFieldU64(sfOwnerNode, *page);
For objects that relate to two accounts (like Checks), add to both directories:
// Add to destination's directory
if (dstAccountId != accountID_)
{
auto const dstPage = view().dirInsert(
keylet::ownerDir(dstAccountId),
sleCheck->key(),
describeOwnerDir(dstAccountId));
if (!dstPage)
return tecDIR_FULL;
sleCheck->setFieldU64(sfDestinationNode, *dstPage);
}
// Add to source's directory
auto const srcPage = view().dirInsert(
keylet::ownerDir(accountID_),
sleCheck->key(),
describeOwnerDir(accountID_));
if (!srcPage)
return tecDIR_FULL;
sleCheck->setFieldU64(sfOwnerNode, *srcPage);
When deleting a ledger object, remove it from all directories:
// Remove from owner's directory
view().dirRemove(
keylet::ownerDir(owner),
sle->getFieldU64(sfOwnerNode), // Page where it was stored
sle->key(), // Key of the entry
true); // keepRoot: keep the empty root page
// Remove from destination's directory if applicable
if (sle->isFieldPresent(sfDestinationNode))
{
view().dirRemove(
keylet::ownerDir(destination),
sle->getFieldU64(sfDestinationNode),
sle->key(),
true);
}
Passing false for keepRoot would delete the directory's root page once it becomes empty. The codebase convention for owner directories is to pass true and keep the root page — that is what the Check transactors do (CheckCancel.cpp, CheckCash.cpp) and what Transactor::ticketDelete does.
In brief: keep the owner count accurate so reserves are charged correctly.
Each account tracks how many ledger objects it owns via the sfOwnerCount field. This count affects the account's reserve requirement.
When creating a new owned object:
// Get the account entry
auto const sle = view().peek(keylet::account(accountID_));
// Increment owner count
adjustOwnerCount(view(), sle, 1, j_);
When deleting an owned object:
// Get the account entry
auto const sle = view().peek(keylet::account(owner));
// Decrement owner count
adjustOwnerCount(view(), sle, -1, j_);
// Declared in include/xrpl/ledger/helpers/AccountRootHelpers.h
void adjustOwnerCount(
ApplyView& view,
std::shared_ptr<SLE> const& sle,
std::int32_t amount, // +1 or -1
beast::Journal j);
This function:
In brief: verify the account can afford a new object using preFeeBalance_ before creating it.
Before creating a new object, verify the account can afford the increased reserve:
TER CheckCreate::doApply()
{
auto const sle = view().peek(keylet::account(accountID_));
if (!sle)
return tefINTERNAL;
// Calculate reserve with one additional object
STAmount const reserve{
view().fees().accountReserve(
sle->getFieldU32(sfOwnerCount) + 1)};
// Check against balance BEFORE fee deduction
if (preFeeBalance_ < reserve)
return tecINSUFFICIENT_RESERVE;
// Proceed with creating the object...
}
Why use preFeeBalance_?
The reserve is checked against the balance before the transaction fee is deducted. This allows accounts to dip into their reserve to pay fees, which is important for cleaning up objects when an account is low on funds.
Watch out. Always check the reserve against
preFeeBalance_before creating an owned object, and always keep the owner count in step. Getting either wrong corrupts an account's reserve accounting.
All changes made through views are staged and only committed if the transaction succeeds. If the transaction fails with a tec code:
This ensures that failed transactions never leave the ledger in an inconsistent state.
tesSUCCESS: Changes are committedtec*: Changes are reverted, but fee/sequence applied// Get XRP balance
// Declared in include/xrpl/ledger/helpers/TokenHelpers.h
STAmount accountHolds(
ReadView const& view,
AccountID const& account,
Currency const& currency,
AccountID const& issuer,
FreezeHandling zeroIfFrozen,
beast::Journal j);
// Get liquid XRP (available after reserves)
// Declared in include/xrpl/ledger/helpers/AccountRootHelpers.h
XRPAmount xrpLiquid(
ReadView const& view,
AccountID const& id,
std::int32_t ownerCountAdj, // Adjust for pending changes
beast::Journal j);
// Check if an issuer has globally frozen
// Declared in include/xrpl/ledger/helpers/AccountRootHelpers.h
bool isGlobalFrozen(ReadView const& view, AccountID const& issuer);
// Check if a specific trust line is frozen
// Declared in include/xrpl/ledger/helpers/RippleStateHelpers.h
bool isFrozen(
ReadView const& view,
AccountID const& account,
Currency const& currency,
AccountID const& issuer);
// Check if a time has passed (uses parent close time)
// Declared in include/xrpl/ledger/View.h
bool hasExpired(
ReadView const& view,
std::optional<std::uint32_t> const& exp);
peek() or read() before accessing fieldstecINSUFFICIENT_RESERVE failurespreFeeBalance_ for reserve checks: Allow fee payment from reserves| File | Description |
|---|---|
include/xrpl/ledger/ReadView.h |
ReadView interface |
include/xrpl/ledger/ApplyView.h |
ApplyView interface |
include/xrpl/ledger/View.h |
View utilities (hasExpired, ...) |
include/xrpl/ledger/helpers/ |
Ledger helper functions: adjustOwnerCount, xrpLiquid, isGlobalFrozen (AccountRootHelpers.h), accountHolds (TokenHelpers.h), isFrozen (RippleStateHelpers.h), describeOwnerDir (DirectoryHelpers.h) |
src/libxrpl/ledger/View.cpp |
View implementation |
include/xrpl/protocol/Indexes.h |
Keylet definitions |
Transaction fees and sequence numbers are fundamental mechanisms that ensure the XRP Ledger operates securely and efficiently. Fees prevent spam and compensate the network for processing transactions, while sequence numbers prevent replay attacks and ensure transaction ordering.
Understanding how these mechanisms work is essential for implementing transactors correctly and for building applications that submit transactions reliably.
Every transaction on the XRP Ledger requires a fee, paid in XRP. This fee is destroyed (burned), permanently removing it from circulation.
The base fee for a transaction is computed by Transactor::calculateBaseFee (src/libxrpl/tx/Transactor.cpp):
XRPAmount
Transactor::calculateBaseFee(ReadView const& view, STTx const& tx)
{
// Returns the fee in fee units.
// The computation has two parts:
// * The base fee, which is the same for most transactions.
// * The additional cost of each multisignature on the transaction.
XRPAmount const baseFee = view.fees().base;
// Each signer adds one more baseFee to the minimum required fee
// for the transaction.
std::size_t const signerCount =
tx.isFieldPresent(sfSigners) ? tx.getFieldArray(sfSigners).size() : 0;
return baseFee + (signerCount * baseFee);
}
The reference base fee (view.fees().base) is currently 10 drops (0.00001 XRP) on mainnet. It is a ledger setting decided by validator fee voting, not a protocol constant — sidenets and test networks can differ.
Different transaction types may require different fees. As the function above shows, each multisignature adds one base fee to the minimum required fee. Transaction types with heavier costs override calculateBaseFee; there is also an overload that adds view.fees().base * extraBaseFeeMultiplier on top for transactions that carry extra work.
Two separate mechanisms raise the fee a transaction needs when the server or network is busy:
Transactor::minimumFee scales the base fee by the local server's load, via scaleFeeLoad and the server's LoadFeeTrack. This is what checkFee compares against on an open ledger, so telINSUF_FEE_P comes from load scaling.src/xrpld/app/misc/detail/TxQ.cpp): as the open ledger fills, the fee required to enter it escalates, and transactions that don't pay it are queued instead.minimumFee itself is small (src/libxrpl/tx/Transactor.cpp):
XRPAmount
Transactor::minimumFee(
ServiceRegistry& registry,
XRPAmount baseFee,
Fees const& fees,
ApplyFlags flags)
{
return scaleFeeLoad(baseFee, registry.getFeeTrack(), fees, (flags & TapUnlimited) != 0u);
}
The TapUnlimited flag (include/xrpl/ledger/ApplyView.h) exempts a transaction from load scaling.
The base Transactor class checks fees during preclaim (src/libxrpl/tx/Transactor.cpp):
TER
Transactor::checkFee(PreclaimContext const& ctx, XRPAmount baseFee)
{
if (!ctx.tx[sfFee].native())
return temBAD_FEE;
auto const feePaid = ctx.tx[sfFee].xrp();
if ((ctx.flags & TapBatch) != 0u)
{
if (feePaid == beast::kZero)
return tesSUCCESS;
JLOG(ctx.j.trace()) << "Batch: Fee must be zero.";
return temBAD_FEE; // LCOV_EXCL_LINE
}
if (!isLegalAmount(feePaid) || feePaid < beast::kZero)
return temBAD_FEE;
// Only check fee is sufficient when the ledger is open.
if (ctx.view.open())
{
auto const feeDue = minimumFee(ctx.registry, baseFee, ctx.view.fees(), ctx.flags);
if (feePaid < feeDue)
{
JLOG(ctx.j.trace()) << "Insufficient fee paid: " << to_string(feePaid) << "/"
<< to_string(feeDue);
return telINSUF_FEE_P;
}
}
if (feePaid == beast::kZero)
return tesSUCCESS;
auto const id = ctx.tx.getFeePayer();
auto const sle = ctx.view.read(keylet::account(id));
if (!sle)
return terNO_ACCOUNT;
auto const balance = (*sle)[sfBalance].xrp();
// NOTE: Because preclaim evaluates against a static readview, it
// does not reflect fee deductions from other transactions paid by
// the same account within the current ledger.
// As a result, if an account's balance is over-committed across multiple
// transactions, this check may pass optimistically.
// The fee shortfall will be handled by the Transactor::reset mechanism,
// which caps the fee to the remaining actual balance.
if (balance < feePaid)
{
JLOG(ctx.j.trace()) << "Insufficient balance:" << " balance=" << to_string(balance)
<< " paid=" << to_string(feePaid);
if ((balance > beast::kZero) && !ctx.view.open())
{
// Closed ledger, non-zero balance, less than fee
return tecINSUFF_FEE;
}
return terINSUF_FEE_B;
}
return tesSUCCESS;
}
Points worth noting: the sufficiency check compares against the load-scaled minimumFee(...), and only on an open ledger; the account read uses ctx.tx.getFeePayer(), not ctx.tx[sfAccount]; a missing account returns terNO_ACCOUNT; and on a closed ledger a nonzero balance below the fee returns tecINSUFF_FEE rather than terINSUF_FEE_B.
Fees are deducted in Transactor::apply(), before doApply() is invoked. apply() records preFeeBalance_, consumes the sequence number or Ticket, then charges the fee via payFee():
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();
}
payFee() subtracts the fee from the fee payer's sfBalance:
TER
Transactor::payFee()
{
auto const feePaid = ctx_.tx[sfFee].xrp();
auto const feePayer = ctx_.tx.getFeePayer();
auto const sle = view().peek(keylet::account(feePayer));
if (!sle)
return tefINTERNAL; // LCOV_EXCL_LINE
// Deduct the fee, so it's not available during the transaction.
// Will only write the account back if the transaction succeeds.
sle->setFieldAmount(sfBalance, sle->getFieldAmount(sfBalance) - feePaid);
if (feePayer != accountID_)
view().update(sle); // done in `apply()` for the account
// VFALCO Should we call view().rawDestroyXRP() here as well?
return tesSUCCESS;
}
The fee has no destination; it is destroyed. Because the fee comes out before doApply() runs, sfBalance is already post-fee by the time your transactor's doApply() executes — which is exactly why preFeeBalance_ exists.
Accounts must maintain a minimum XRP balance called the reserve. The reserve has two components:
Both values come from the ledger's Fees structure (include/xrpl/protocol/Fees.h) and are set by validator fee voting; they are not protocol constants, and sidenets and test networks differ.
STAmount accountReserve = view().fees().accountReserve(ownerCount);
// This is equivalent to:
// baseReserve + (ownerCount * ownerReserve)
// e.g., 1 XRP + (5 objects * 0.2 XRP) = 2 XRP
Before creating a new object that will increase the owner count, verify the account can afford it:
TER CheckCreate::doApply()
{
auto const sle = view().peek(keylet::account(accountID_));
// Calculate reserve with one additional object
STAmount const reserve{
view().fees().accountReserve(
sle->getFieldU32(sfOwnerCount) + 1)};
// Use preFeeBalance_ (before fee deduction)
if (preFeeBalance_ < reserve)
return tecINSUFFICIENT_RESERVE;
// Continue with object creation...
}
Why preFeeBalance_?
Using the balance before fee deduction allows accounts to use reserve XRP to pay transaction fees. This is important for:
Each account root carries a sequence number (sfSequence) that increments with each sequence-based transaction. Since the DeletableAccounts amendment, a newly created account's sequence starts at the sequence of the ledger in which the account is created — from the account-creating branch of Payment::doApply() (src/libxrpl/tx/transactors/payment/Payment.cpp):
sleDst = std::make_shared<SLE>(k);
sleDst->setAccountID(sfAccount, dstAccountID);
sleDst->setFieldU32(sfSequence, view().seq());
Sequence numbers prevent:
Sequence checking is built on SeqProxy, which covers both plain sequences and tickets. Note that STTx has no getSequence() accessor — the accessors are getSeqProxy() and getSeqValue(). The real function (src/libxrpl/tx/Transactor.cpp):
NotTEC
Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j)
{
auto const id = tx.getAccountID(sfAccount);
auto const sle = view.read(keylet::account(id));
if (!sle)
{
JLOG(j.trace()) << "applyTransaction: delay: source account does not exist "
<< toBase58(id);
return terNO_ACCOUNT;
}
SeqProxy const tSeqProx = tx.getSeqProxy();
SeqProxy const aSeq = SeqProxy::sequence((*sle)[sfSequence]);
if (tSeqProx.isSeq())
{
if (tx.isFieldPresent(sfTicketSequence))
{
JLOG(j.trace()) << "applyTransaction: has both a TicketSequence "
"and a non-zero Sequence number";
return temSEQ_AND_TICKET;
}
if (tSeqProx != aSeq)
{
if (aSeq < tSeqProx)
{
JLOG(j.trace()) << "applyTransaction: has future sequence number "
<< "a_seq=" << aSeq << " t_seq=" << tSeqProx;
return terPRE_SEQ;
}
// It's an already-used sequence number.
JLOG(j.trace()) << "applyTransaction: has past sequence number "
<< "a_seq=" << aSeq << " t_seq=" << tSeqProx;
return tefPAST_SEQ;
}
}
else if (tSeqProx.isTicket())
{
// Bypass the type comparison. Apples and oranges.
if (aSeq.value() <= tSeqProx.value())
{
// If the Ticket number is greater than or equal to the
// account sequence there's the possibility that the
// transaction to create the Ticket has not hit the ledger
// yet. Allow a retry.
JLOG(j.trace()) << "applyTransaction: has future ticket id "
<< "a_seq=" << aSeq << " t_seq=" << tSeqProx;
return terPRE_TICKET;
}
// Transaction can never succeed if the Ticket is not in the ledger.
if (!view.exists(keylet::ticket(id, tSeqProx)))
{
JLOG(j.trace()) << "applyTransaction: ticket already used or never created "
<< "a_seq=" << aSeq << " t_seq=" << tSeqProx;
return tefNO_TICKET;
}
}
return tesSUCCESS;
}
For a plain sequence: too high returns terPRE_SEQ, already used returns tefPAST_SEQ. For a ticket: a ticket number at or above the account sequence returns terPRE_TICKET (the TicketCreate may not have hit the ledger yet), and a missing Ticket entry returns tefNO_TICKET.
The sequence number or Ticket is consumed in Transactor::apply(), before doApply() runs (and it stays consumed on both tesSUCCESS and tec failures):
TER
Transactor::consumeSeqProxy(SLE::pointer const& sleAccount)
{
XRPL_ASSERT(sleAccount, "xrpl::Transactor::consumeSeqProxy : non-null account");
SeqProxy const seqProx = ctx_.tx.getSeqProxy();
if (seqProx.isSeq())
{
// Note that if this transaction is a TicketCreate, then
// the transaction will modify the account root sfSequence
// yet again.
sleAccount->setFieldU32(sfSequence, seqProx.value() + 1);
return tesSUCCESS;
}
return ticketDelete(view(), accountID_, getTicketIndex(accountID_, seqProx), j_);
}
For a sequence-based transaction, sfSequence is incremented. For a ticket-based transaction, the account sequence is not incremented — instead the Ticket is deleted via Transactor::ticketDelete, which removes it from the owner directory (dirRemove with keepRoot true), decrements sfTicketCount, and calls adjustOwnerCount(view, sleAccount, -1, j), releasing the Ticket's reserve.
Tickets provide an alternative to strict sequence ordering. A ticket is a pre-reserved sequence number that can be used later.
The TicketCreate transaction reserves a range of sequence numbers:
// Reserve 10 tickets
{
"TransactionType": "TicketCreate",
"Account": "rAccount...",
"TicketCount": 10
}
Instead of Sequence, use TicketSequence:
// Use ticket #5 instead of the account sequence
{
"TransactionType": "Payment",
"Account": "rAccount...",
"TicketSequence": 5,
// No "Sequence" field
}
A Ticket is single-use: as shown in consumeSeqProxy above, applying a ticket-based transaction deletes the Ticket entry and releases its owner reserve.
// Get the sequence value (works for both regular sequence and tickets)
std::uint32_t const seq = ctx_.tx.getSeqValue();
// For creating objects, use this value as the identifier
Keylet const checkKeylet = keylet::check(accountID_, seq);
Transactions can specify a maximum ledger sequence for inclusion:
{
"TransactionType": "Payment",
"Account": "rAccount...",
"Sequence": 42,
"LastLedgerSequence": 75000000 // Expire after this ledger
}
If the transaction is not included by this ledger, it becomes invalid. The check lives in Transactor::checkPriorTxAndLastLedger (src/libxrpl/tx/Transactor.cpp), which — as its name says — also enforces sfAccountTxnID (returning tefWRONG_PRIOR on a mismatch) and rejects duplicate transaction IDs (tefALREADY):
NotTEC
Transactor::checkPriorTxAndLastLedger(PreclaimContext const& ctx)
{
auto const id = ctx.tx.getAccountID(sfAccount);
auto const sle = ctx.view.read(keylet::account(id));
if (!sle)
{
JLOG(ctx.j.trace()) << "applyTransaction: delay: source account does not exist "
<< toBase58(id);
return terNO_ACCOUNT;
}
if (ctx.tx.isFieldPresent(sfAccountTxnID) &&
(sle->getFieldH256(sfAccountTxnID) != ctx.tx.getFieldH256(sfAccountTxnID)))
return tefWRONG_PRIOR;
if (ctx.tx.isFieldPresent(sfLastLedgerSequence) &&
(ctx.view.seq() > ctx.tx.getFieldU32(sfLastLedgerSequence)))
return tefMAX_LEDGER;
if (ctx.view.txExists(ctx.tx.getTransactionID()))
return tefALREADY;
return tesSUCCESS;
}
Best Practice: Always set LastLedgerSequence to prevent transactions from being stuck indefinitely. A common value is current_ledger + 4.
The Transactor base class records the account's XRP balance before the fee is
deducted, so transaction logic can reason about reserves correctly:
XRPAmount preFeeBalance_{}; // Balance before fees are deducted
preFeeBalance_ is captured in Transactor::apply() — see the extract in the Fee Payment section: apply() records preFeeBalance_, calls consumeSeqProxy(sle), then payFee(), and only then doApply(). Charging the fee is the base class's responsibility; derived transactors never deduct it themselves.
Transactor::reset() is the failure path, not the normal one. It is a private member, called when a transaction fails with a tec code or when an invariant check fails. It discards all staged changes with ctx_.discard(), then re-charges the fee — capped at the fee payer's remaining balance — and re-consumes the sequence number or Ticket. That is how a failed transaction still burns its fee and consumes its sequence even though every other change it made is thrown away.
Usage:
preFeeBalance_ for reserve checks, comparing the pre-fee balance (rather than the
post-fee balance) against the reserve is what lets an account dip into its reserve to pay
the transaction fee.| Code | Meaning | When Returned |
|---|---|---|
temBAD_FEE |
Fee is malformed | Negative or non-XRP fee |
telINSUF_FEE_P |
Fee too low | Below the load-scaled minimum (open ledger) |
terINSUF_FEE_B |
Can't afford fee | Balance < fee |
tecINSUFF_FEE |
Can't afford fee | Nonzero balance below the fee, on a closed ledger |
tecINSUFFICIENT_RESERVE |
Reserve not met | Creating object without enough XRP |
| Code | Meaning | When Returned |
|---|---|---|
tefPAST_SEQ |
Sequence already used | Transaction replayed or sequence too low |
terPRE_SEQ |
Sequence too high | Earlier transaction not yet applied |
temSEQ_AND_TICKET |
Conflicting fields | Both TicketSequence and a non-zero Sequence present |
terPRE_TICKET |
Ticket not yet created | Ticket number at or above the account sequence |
tefMAX_LEDGER |
Transaction expired | LastLedgerSequence exceeded |
tefNO_TICKET |
Ticket not found | TicketSequence doesn't exist |
preFeeBalance_| File | Description |
|---|---|
src/libxrpl/tx/Transactor.cpp |
Fee and sequence handling implementation |
src/xrpld/app/misc/detail/TxQ.cpp |
Transaction queue and open-ledger fee escalation |
include/xrpl/protocol/Fees.h |
Fee structure definitions |
This module covered how a transactor actually changes the ledger. You worked with the ledger views (ReadView, ApplyView, OpenView), created, read, modified, and deleted serialized ledger entries (SLEs) through keylets, and handled the three things every transactor must get right: fees, reserves (checked against preFeeBalance_), and sequence numbers (including tickets and SeqProxy). This is the toolkit doApply is built from.
To remember:
ReadView (read-only), ApplyView (read/write), OpenView (the open ledger)view().peek() / read(), then insert() / update() / erase() — a mutation to a peeked SLE without update() is silently droppedkeylet::account/check/trustLine/... computes an entry's key (include/xrpl/protocol/Indexes.h)accountReserve(ownerCount + 1) vs preFeeBalance_dirInsert into the owner directory + adjustOwnerCountSeqProxyTransactor::apply() calls payFee() before doApply(); your transactor checks but never deductsNext up. Every transaction ends with a verdict. Next you learn to read them all: the TER result codes, and what each family promises about fees and retries.
Resources
Assignments
0 of 2 complete