advanced 60 min

State modification, fees & sequences

How a transactor modifies ledger state through views, and how fees, reserves and sequence numbers are handled.

Prerequisites

Complete these before starting this module:

What you'll learn

  • Modify ledger entries (SLE) through ApplyView and keylets.
  • Explain fee deduction, reserves and `preFeeBalance_`.
  • Understand sequence numbers, tickets and SeqProxy.
Complete this module by self-assessment and a quiz. Jump to assessment

Introduction

≈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.


Ledger Views

In brief: ReadView, ApplyView and OpenView, the read-only and read/write windows onto ledger state.

The view system provides three levels of access:

ReadView

Read-only access to ledger state. Used in preclaim:

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();
    // ...
}

ApplyView

Read/write access to ledger state. Used in doApply:

SLE::pointer is std::shared_ptr<STLedgerEntry> and SLE::ref is std::shared_ptr<STLedgerEntry> const& (include/xrpl/protocol/STLedgerEntry.h).

OpenView

The writable view representing the open ledger. It accumulates state and transaction changes as incoming transactions are applied against it.


Serialized Ledger Entries (SLEs)

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:

  • A type (AccountRoot, Check, Offer, TrustLine, etc.)
  • A key (256-bit unique identifier)
  • Fields specific to that type

Creating an SLE

Reading an SLE

// 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);

Modifying an SLE

// 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 as Action::Cache in the ApplyStateTable, and cached items are skipped both when the state table is applied and when transaction metadata is built (src/libxrpl/ledger/ApplyStateTable.cpp). Only update() promotes the entry to Action::Modify. Mutating a peeked SLE without calling update() silently drops the change.

Deleting an SLE


Keylets

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);

Directory Management

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.

Adding to a Directory

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:

Removing from a Directory

When deleting a ledger object, remove it from all directories:

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.


Owner Count Management

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.

Incrementing Owner Count

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_);

Decrementing Owner Count

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_);

The adjustOwnerCount Function

// 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:

  1. Gets the current owner count
  2. Adds the adjustment
  3. Updates the account SLE

Reserve Checking

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:

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.


Atomic Operations

All changes made through views are staged and only committed if the transaction succeeds. If the transaction fails with a tec code:

  1. All state changes are reverted
  2. The fee is still charged
  3. The sequence number is still consumed

This ensures that failed transactions never leave the ledger in an inconsistent state.

How Atomicity Works

  1. Staging: Changes are made to a view layer, not the actual ledger
  2. Validation: All invariants are checked
  3. Commit or Rollback:
  • On tesSUCCESS: Changes are committed
  • On tec*: Changes are reverted, but fee/sequence applied
  • On other failures: Nothing is applied

Common Utility Functions

Reading Account Balances

Checking Freeze Status

// 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);

Checking Expiration

// 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);

Best Practices

  1. Always check entry existence: Use peek() or read() before accessing fields
  2. Use keylets for type safety: Don't construct keys manually
  3. Update directories on create/delete: Maintain bidirectional links
  4. Update owner count on create/delete: Keep reserve calculations correct
  5. Check reserves before creating: Prevent tecINSUFFICIENT_RESERVE failures
  6. Remove from directories before erasing: Clean up all references
  7. Use preFeeBalance_ for reserve checks: Allow fee payment from reserves

Codebase References

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

Fee and Sequence Handling

What one payment moves: the amount reaches the destination, the 10-drop fee is destroyed forever, and the sequence increments from 7 to 8 even if the transaction fails with a tec code.


Introduction

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.


Transaction Fees

Every transaction on the XRP Ledger requires a fee, paid in XRP. This fee is destroyed (burned), permanently removing it from circulation.

Base Fee

The base fee for a transaction is computed by Transactor::calculateBaseFee (src/libxrpl/tx/Transactor.cpp):

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.

Fee Calculation

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.

Load Scaling and Fee Escalation

Two separate mechanisms raise the fee a transaction needs when the server or network is busy:

  1. Load scaling. 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.
  2. Open-ledger fee escalation. A separate mechanism inside the transaction queue (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.

Fee Checking in Preclaim

The base Transactor class checks fees during preclaim (src/libxrpl/tx/Transactor.cpp):

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.

Fee Payment

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():

payFee() subtracts the fee from the fee payer's sfBalance:

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.


Account Reserves

Accounts must maintain a minimum XRP balance called the reserve. The reserve has two components:

  1. Base Reserve: Amount every account must hold (currently 1 XRP on mainnet)
  2. Owner Reserve: Additional amount per owned object (currently 0.2 XRP per object on mainnet)

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.

Reserve Calculation

STAmount accountReserve = view().fees().accountReserve(ownerCount);

// This is equivalent to:
// baseReserve + (ownerCount * ownerReserve)
// e.g., 1 XRP + (5 objects * 0.2 XRP) = 2 XRP

Checking Reserves Before Creating Objects

Before creating a new object that will increase the owner count, verify the account can afford it:

Why preFeeBalance_?

Using the balance before fee deduction allows accounts to use reserve XRP to pay transaction fees. This is important for:

  • Deleting objects when an account is low on funds
  • Sending the last XRP out of an account

Sequence Numbers

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:

  • Replay attacks: A transaction can only be applied once
  • Transaction ordering issues: Transactions are applied in sequence order

Sequence Number Checking

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):

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.

Sequence Number Consumption

The sequence number or Ticket is consumed in Transactor::apply(), before doApply() runs (and it stays consumed on both tesSUCCESS and tec failures):

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

Tickets provide an alternative to strict sequence ordering. A ticket is a pre-reserved sequence number that can be used later.

Creating Tickets

The TicketCreate transaction reserves a range of sequence numbers:

// Reserve 10 tickets
{
    "TransactionType": "TicketCreate",
    "Account": "rAccount...",
    "TicketCount": 10
}

Using Tickets

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.

Ticket Handling in Transactors

// 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);

LastLedgerSequence

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):

Best Practice: Always set LastLedgerSequence to prevent transactions from being stuck indefinitely. A common value is current_ledger + 4.


preFeeBalance_

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:

  • Use 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

Best Practices

  1. Always set LastLedgerSequence: Prevent stuck transactions
  2. Check reserves before creating objects: Use preFeeBalance_
  3. Handle sequence gaps: Use tickets for out-of-order transactions
  4. Account for fee escalation: During high load, fees increase
  5. Don't hardcode fees: Query the current fee level
  6. Consider ticket usage: For systems that need flexible ordering

Codebase References

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

Summary

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:

  • Views: ReadView (read-only), ApplyView (read/write), OpenView (the open ledger)
  • SLE lifecycle: view().peek() / read(), then insert() / update() / erase() — a mutation to a peeked SLE without update() is silently dropped
  • keylet::account/check/trustLine/... computes an entry's key (include/xrpl/protocol/Indexes.h)
  • Reserve check BEFORE creating an object: accountReserve(ownerCount + 1) vs preFeeBalance_
  • Owned objects must be wired: dirInsert into the owner directory + adjustOwnerCount
  • Sequences: plain sequence or Tickets, unified behind SeqProxy
  • Fees are charged by the engine — Transactor::apply() calls payFee() before doApply(); your transactor checks but never deducts
  • Watch out: creating an owned object without dirInsert + adjustOwnerCount silently corrupts the account's reserve accounting

Next 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.

Assignments

0 of 2 complete

XRPL Academy © 2026