Working with the Ledger
Learning Objectives
Query account information including balances, settings, and owned objects
Retrieve transaction history for accounts and specific transactions
Understand ledger indexes and the difference between validated and current state
Implement pagination to handle large result sets efficiently
Apply rate limiting best practices for reliable API access
So far, you've learned to write to XRPL—creating accounts and sending payments. Now we need to read from it. Every application needs to:
- Check balances before sending payments
- Display transaction history to users
- Verify that transactions actually succeeded
- Monitor accounts for incoming payments
- Query the current network state
XRPL provides a comprehensive API for all of this. The challenge isn't capability—it's understanding which endpoints to use and how to use them correctly.
The key insight: XRPL distinguishes between "current" (latest proposed state) and "validated" (consensus-confirmed) data. This distinction matters for accuracy.
The most fundamental query retrieves an account's current state:
// src/queries/account-info.js
const xrpl = require('xrpl');
async function getAccountInfo(address, options = {}) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const request = {
command: 'account_info',
account: address,
ledger_index: options.ledger || 'validated', // Use validated for accuracy
queue: options.includeQueue || false, // Include queued transactions
signer_lists: options.includeSigners || false // Include signer lists
};
const response = await client.request(request);
const data = response.result.account_data;
return {
address: data.Account,
balance: {
drops: data.Balance,
xrp: Number(data.Balance) / 1_000_000
},
sequence: data.Sequence,
ownerCount: data.OwnerCount,
flags: parseAccountFlags(data.Flags),
previousTxn: {
hash: data.PreviousTxnID,
ledger: data.PreviousTxnLgrSeq
},
ledgerIndex: response.result.ledger_index,
validated: response.result.validated
};
} catch (error) {
if (error.data?.error === 'actNotFound') {
return {
address: address,
exists: false,
error: 'Account not found or not activated'
};
}
throw error;
} finally {
await client.disconnect();
}
}
function parseAccountFlags(flags) {
// Common account flags
return {
defaultRipple: (flags & 0x00800000) !== 0,
depositAuth: (flags & 0x01000000) !== 0,
disableMaster: (flags & 0x00100000) !== 0,
disallowXRP: (flags & 0x00080000) !== 0,
globalFreeze: (flags & 0x00400000) !== 0,
noFreeze: (flags & 0x00200000) !== 0,
passwordSpent: (flags & 0x00010000) !== 0,
requireAuth: (flags & 0x00040000) !== 0,
requireDestTag: (flags & 0x00020000) !== 0
};
}
module.exports = { getAccountInfo, parseAccountFlags };
Get all objects owned by an account (trust lines, offers, etc.):
// src/queries/account-objects.js
const xrpl = require('xrpl');
async function getAccountObjects(address, options = {}) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const allObjects = [];
let marker = undefined;
do {
const request = {
command: 'account_objects',
account: address,
ledger_index: 'validated',
limit: options.limit || 200,
type: options.type, // Filter by type: 'offer', 'state' (trust line), etc.
marker: marker
};
const response = await client.request(request);
allObjects.push(...response.result.account_objects);
marker = response.result.marker;
// Optional: limit total results
if (options.maxResults && allObjects.length >= options.maxResults) {
break;
}
} while (marker);
// Categorize objects
const categorized = {
trustLines: [],
offers: [],
escrows: [],
paymentChannels: [],
checks: [],
nftOffers: [],
other: []
};
for (const obj of allObjects) {
switch (obj.LedgerEntryType) {
case 'RippleState':
categorized.trustLines.push(formatTrustLine(obj));
break;
case 'Offer':
categorized.offers.push(formatOffer(obj));
break;
case 'Escrow':
categorized.escrows.push(obj);
break;
case 'PayChannel':
categorized.paymentChannels.push(obj);
break;
case 'Check':
categorized.checks.push(obj);
break;
case 'NFTokenOffer':
categorized.nftOffers.push(obj);
break;
default:
categorized.other.push(obj);
}
}
return {
total: allObjects.length,
...categorized
};
} finally {
await client.disconnect();
}
}
function formatTrustLine(state) {
// Trust lines store balance from the perspective of the low account
return {
currency: state.Balance.currency,
issuer: state.HighLimit.issuer || state.LowLimit.issuer,
balance: state.Balance.value,
limit: state.HighLimit.value || state.LowLimit.value
};
}
function formatOffer(offer) {
return {
sequence: offer.Sequence,
takerPays: offer.TakerPays,
takerGets: offer.TakerGets,
expiration: offer.Expiration,
flags: offer.Flags
};
}
module.exports = { getAccountObjects };
A more convenient way to get just trust lines:
// src/queries/account-lines.js
const xrpl = require('xrpl');
async function getAccountLines(address) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const allLines = [];
let marker = undefined;
do {
const response = await client.request({
command: 'account_lines',
account: address,
ledger_index: 'validated',
limit: 400,
marker: marker
});
allLines.push(...response.result.lines);
marker = response.result.marker;
} while (marker);
return allLines.map(line => ({
account: line.account, // The counterparty
currency: line.currency, // Currency code
balance: line.balance, // Your balance (negative = you owe them)
limit: line.limit, // Your limit
limitPeer: line.limit_peer, // Their limit
noRipple: line.no_ripple, // Rippling disabled on your side
noRipplePeer: line.no_ripple_peer // Rippling disabled on their side
}));
} finally {
await client.disconnect();
}
}
module.exports = { getAccountLines };
Retrieve transaction history for an account:
// src/queries/account-transactions.js
const xrpl = require('xrpl');
async function getAccountTransactions(address, options = {}) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const transactions = [];
let marker = undefined;
const maxTransactions = options.limit || 100;
do {
const request = {
command: 'account_tx',
account: address,
ledger_index_min: options.minLedger || -1, // -1 = earliest available
ledger_index_max: options.maxLedger || -1, // -1 = latest available
binary: false, // Get JSON, not binary
forward: options.forward || false, // false = newest first
limit: Math.min(200, maxTransactions - transactions.length),
marker: marker
};
const response = await client.request(request);
for (const txData of response.result.transactions) {
transactions.push(formatTransaction(txData, address));
if (transactions.length >= maxTransactions) {
break;
}
}
marker = response.result.marker;
} while (marker && transactions.length < maxTransactions);
return {
account: address,
transactions: transactions,
count: transactions.length
};
} finally {
await client.disconnect();
}
}
function formatTransaction(txData, accountAddress) {
const tx = txData.tx;
const meta = txData.meta;
const formatted = {
hash: tx.hash,
type: tx.TransactionType,
ledger: tx.ledger_index,
date: rippleTimeToDate(tx.date),
fee: Number(tx.Fee) / 1_000_000,
result: meta.TransactionResult,
success: meta.TransactionResult === 'tesSUCCESS'
};
// Add type-specific details
if (tx.TransactionType === 'Payment') {
const isOutgoing = tx.Account === accountAddress;
formatted.direction = isOutgoing ? 'sent' : 'received';
formatted.counterparty = isOutgoing ? tx.Destination : tx.Account;
formatted.amount = parseAmount(meta.delivered_amount || tx.Amount);
if (tx.DestinationTag) {
formatted.destinationTag = tx.DestinationTag;
}
}
return formatted;
}
function parseAmount(amount) {
if (typeof amount === 'string') {
// XRP amount in drops
return {
currency: 'XRP',
value: Number(amount) / 1_000_000
};
} else {
// Issued currency
return {
currency: amount.currency,
issuer: amount.issuer,
value: Number(amount.value)
};
}
}
function rippleTimeToDate(rippleTime) {
// Ripple epoch is January 1, 2000 (946684800 seconds after Unix epoch)
const unixTime = rippleTime + 946684800;
return new Date(unixTime * 1000);
}
module.exports = { getAccountTransactions, formatTransaction, rippleTimeToDate };
Look up a specific transaction by hash:
// src/queries/transaction-lookup.js
const xrpl = require('xrpl');
async function getTransaction(txHash, options = {}) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const response = await client.request({
command: 'tx',
transaction: txHash,
binary: false
});
const result = response.result;
return {
hash: result.hash,
type: result.TransactionType,
account: result.Account,
ledger: result.ledger_index,
validated: result.validated,
result: result.meta?.TransactionResult,
success: result.meta?.TransactionResult === 'tesSUCCESS',
// Full transaction data
transaction: result,
metadata: result.meta,
// Balance changes
balanceChanges: options.includeChanges
? parseBalanceChanges(result.meta)
: undefined
};
} catch (error) {
if (error.data?.error === 'txnNotFound') {
return {
hash: txHash,
found: false,
error: 'Transaction not found'
};
}
throw error;
} finally {
await client.disconnect();
}
}
function parseBalanceChanges(meta) {
const changes = [];
if (!meta?.AffectedNodes) return changes;
for (const node of meta.AffectedNodes) {
const nodeData = node.ModifiedNode || node.CreatedNode || node.DeletedNode;
if (nodeData?.LedgerEntryType === 'AccountRoot') {
const final = nodeData.FinalFields || nodeData.NewFields;
const previous = nodeData.PreviousFields;
if (final && previous?.Balance) {
const account = final.Account;
const oldBalance = Number(previous.Balance) / 1_000_000;
const newBalance = Number(final.Balance) / 1_000_000;
const change = newBalance - oldBalance;
changes.push({
account,
currency: 'XRP',
change: change,
oldBalance,
newBalance
});
}
}
}
return changes;
}
module.exports = { getTransaction, parseBalanceChanges };
XRPL maintains two views of ledger state:
| Ledger Index | Meaning | Use Case |
|---|---|---|
validated |
Last consensus-confirmed state | Account balances, transaction verification |
current |
Tentative next state | Fee estimation, sequence prediction |
closed |
Most recently closed (may not be validated yet) | Rarely used |
| Specific number | Historical ledger | Audit, historical analysis |
// src/queries/ledger-comparison.js
const xrpl = require('xrpl');
async function compareLedgerStates(address) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
// Query validated (confirmed) state
const validated = await client.request({
command: 'account_info',
account: address,
ledger_index: 'validated'
});
// Query current (proposed) state
const current = await client.request({
command: 'account_info',
account: address,
ledger_index: 'current'
});
return {
validated: {
ledger: validated.result.ledger_index,
balance: validated.result.account_data.Balance,
sequence: validated.result.account_data.Sequence
},
current: {
ledger: current.result.ledger_index,
balance: current.result.account_data.Balance,
sequence: current.result.account_data.Sequence
},
differences: {
balanceChanged: validated.result.account_data.Balance !==
current.result.account_data.Balance,
sequenceChanged: validated.result.account_data.Sequence !==
current.result.account_data.Sequence
}
};
} finally {
await client.disconnect();
}
}
Best Practice: Always use ledger_index: 'validated' for displaying balances to users. Use current only for preparing transactions (fee estimation, sequence numbers).
Get information about the current ledger:
// src/queries/ledger-info.js
const xrpl = require('xrpl');
async function getLedgerInfo(ledgerIndex = 'validated') {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const response = await client.request({
command: 'ledger',
ledger_index: ledgerIndex,
transactions: false, // Don't include all transactions
expand: false
});
const ledger = response.result.ledger;
return {
index: ledger.ledger_index,
hash: ledger.ledger_hash,
parentHash: ledger.parent_hash,
closeTime: rippleTimeToDate(ledger.close_time),
transactionCount: ledger.transaction_hash ? 1 : 0, // Simplified
validated: response.result.validated,
// Useful metrics
totalCoins: Number(ledger.total_coins) / 1_000_000, // Total XRP in existence
accountCount: ledger.accountState?.length // If expanded
};
} finally {
await client.disconnect();
}
}
function rippleTimeToDate(rippleTime) {
return new Date((rippleTime + 946684800) * 1000);
}
module.exports = { getLedgerInfo };
XRPL uses markers for pagination instead of page numbers:
// First request returns:
{
"result": {
"transactions": [...], // First batch
"marker": "ABC123..." // Opaque marker for next batch
}
}
// Second request includes marker:
{
"command": "account_tx",
"account": "r...",
"marker": "ABC123..." // Get next batch
}
- Markers are opaque strings—don't try to parse or modify them
- Markers are only valid for the same query (same account, same filters)
- No marker means you've retrieved all results
// src/queries/paginated-query.js
const xrpl = require('xrpl');
async function* paginatedAccountTx(client, address, options = {}) {
let marker = undefined;
let totalReturned = 0;
const maxResults = options.maxResults || Infinity;
const batchSize = options.batchSize || 200;
do {
const response = await client.request({
command: 'account_tx',
account: address,
ledger_index_min: -1,
ledger_index_max: -1,
limit: Math.min(batchSize, maxResults - totalReturned),
marker: marker
});
for (const tx of response.result.transactions) {
yield tx;
totalReturned++;
if (totalReturned >= maxResults) {
return;
}
}
marker = response.result.marker;
// Optional delay between batches to avoid rate limiting
if (marker && options.delayMs) {
await sleep(options.delayMs);
}
} while (marker);
}
// Usage with async generator
async function getAllTransactions(address) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
try {
const transactions = [];
for await (const tx of paginatedAccountTx(client, address, {
maxResults: 1000,
batchSize: 200,
delayMs: 100 // 100ms between batches
})) {
transactions.push(tx);
// Optional: log progress
if (transactions.length % 100 === 0) {
console.log(Retrieved ${transactions.length} transactions...);
}
}
return transactions;
} finally {
await client.disconnect();
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = { paginatedAccountTx, getAllTransactions };
```
For very large datasets, process records as they arrive instead of collecting all in memory:
// src/queries/stream-processor.js
async function processTransactionHistory(address, processor) {
const client = new xrpl.Client('wss://s.altnet.rippletest.net:51233');
await client.connect();
let marker = undefined;
let processed = 0;
try {
do {
const response = await client.request({
command: 'account_tx',
account: address,
limit: 200,
marker: marker
});
for (const tx of response.result.transactions) {
// Process each transaction immediately
await processor(tx, processed);
processed++;
}
marker = response.result.marker;
} while (marker);
return { processed };
} finally {
await client.disconnect();
}
}
// Example: Calculate total XRP sent
async function calculateTotalSent(address) {
let totalSent = 0;
await processTransactionHistory(address, async (txData) => {
const tx = txData.tx;
const meta = txData.meta;
if (tx.TransactionType === 'Payment' &&
tx.Account === address &&
meta.TransactionResult === 'tesSUCCESS') {
const amount = meta.delivered_amount || tx.Amount;
if (typeof amount === 'string') {
totalSent += Number(amount) / 1_000_000;
}
}
});
return totalSent;
}
```
Public XRPL servers have rate limits (usually undocumented but present):
Requests per second: ~10-50
Requests per minute: ~300-600
Concurrent WebSocket connections: 1-5 per IP
"slowDown" error messages
Connection drops
Increased latency
429/503 HTTP errors (JSON-RPC)
// src/queries/rate-limiter.js
class RateLimiter {
constructor(requestsPerSecond = 10) {
this.minInterval = 1000 / requestsPerSecond;
this.lastRequest = 0;
this.queue = [];
this.processing = false;
}
async acquire() {
return new Promise(resolve => {
this.queue.push(resolve);
this.processQueue();
});
}
async processQueue() {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
while (this.queue.length > 0) {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequest;
if (timeSinceLastRequest < this.minInterval) {
await this.sleep(this.minInterval - timeSinceLastRequest);
}
this.lastRequest = Date.now();
const resolve = this.queue.shift();
resolve();
}
this.processing = false;
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Usage
class RateLimitedClient {
constructor(url, requestsPerSecond = 10) {
this.client = new xrpl.Client(url);
this.limiter = new RateLimiter(requestsPerSecond);
}
async connect() {
await this.client.connect();
}
async disconnect() {
await this.client.disconnect();
}
async request(requestObject) {
await this.limiter.acquire();
return this.client.request(requestObject);
}
}
module.exports = { RateLimiter, RateLimitedClient };
```
Reduce load by caching appropriate data:
// src/queries/cached-queries.js
class CachedAccountInfo {
constructor(ttlMs = 5000) { // 5 second default TTL
this.cache = new Map();
this.ttl = ttlMs;
}
async get(client, address) {
const cached = this.cache.get(address);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
const response = await client.request({
command: 'account_info',
account: address,
ledger_index: 'validated'
});
this.cache.set(address, {
data: response.result.account_data,
timestamp: Date.now()
});
return response.result.account_data;
}
invalidate(address) {
this.cache.delete(address);
}
clear() {
this.cache.clear();
}
}
// What to cache:
// ✓ Account info (short TTL: 5-30 seconds)
// ✓ Server info (medium TTL: 60 seconds)
// ✓ Historical transactions (long TTL: hours or permanent)
// ✗ Current balance for payments (always query fresh)
// ✗ Sequence numbers for transactions (always query fresh)
module.exports = { CachedAccountInfo };
// src/queries/xrpl-queries.js
const xrpl = require('xrpl');
class XRPLQueries {
constructor(client) {
this.client = client;
}
// Account Methods
async getAccountInfo(address) {
const response = await this.client.request({
command: 'account_info',
account: address,
ledger_index: 'validated'
});
return response.result;
}
async getBalance(address) {
const info = await this.getAccountInfo(address);
return {
total: Number(info.account_data.Balance) / 1_000_000,
reserved: 10 + (info.account_data.OwnerCount * 2),
available: Math.max(0,
Number(info.account_data.Balance) / 1_000_000 -
10 - (info.account_data.OwnerCount * 2)
)
};
}
async accountExists(address) {
try {
await this.getAccountInfo(address);
return true;
} catch (error) {
if (error.data?.error === 'actNotFound') {
return false;
}
throw error;
}
}
// Transaction Methods
async getTransaction(hash) {
const response = await this.client.request({
command: 'tx',
transaction: hash
});
return response.result;
}
async getRecentTransactions(address, limit = 20) {
const response = await this.client.request({
command: 'account_tx',
account: address,
limit: limit,
ledger_index_min: -1,
ledger_index_max: -1
});
return response.result.transactions;
}
async *getAllTransactions(address) {
let marker = undefined;
do {
const response = await this.client.request({
command: 'account_tx',
account: address,
limit: 200,
marker: marker
});
for (const tx of response.result.transactions) {
yield tx;
}
marker = response.result.marker;
} while (marker);
}
// Ledger Methods
async getCurrentLedger() {
const response = await this.client.request({
command: 'ledger',
ledger_index: 'validated'
});
return response.result.ledger;
}
async getServerInfo() {
const response = await this.client.request({
command: 'server_info'
});
return response.result.info;
}
}
module.exports = { XRPLQueries };
```
XRPL's query APIs are powerful and well-designed. The main challenges are operational: rate limiting, server selection, and handling the distinction between current and validated state. Get these right and your application will be reliable; get them wrong and you'll have intermittent bugs that are hard to diagnose.
Assignment: Build a comprehensive account viewer that displays balances, owned objects, and transaction history.
Requirements:
Show total, reserved, and available XRP balance
Calculate and explain reserve breakdown (base + owner reserves)
Handle non-existent accounts gracefully
Display last 50 transactions with pagination
Show transaction type, direction (sent/received), amount, and result
Format timestamps in human-readable form
Include transaction hash for reference
Count trust lines, offers, escrows, etc.
Show details for each category
Calculate total owner reserve impact
Look up any transaction by hash
Display full details including balance changes
Show whether transaction is validated
Balance display accurate and clear (25%)
Transaction history correctly paginated (25%)
Owned objects properly categorized (25%)
Error handling and edge cases (25%)
Time investment: 2-3 hours
Value: This viewer will be your primary debugging tool for the rest of the course
Knowledge Check
Question 1 of 1You're retrieving transaction history and receive a response with a `marker` field. What does this indicate?
- account_info: https://xrpl.org/account_info.html
- account_tx: https://xrpl.org/account_tx.html
- account_objects: https://xrpl.org/account_objects.html
- tx method: https://xrpl.org/tx.html
- Ledger data formats: https://xrpl.org/ledger-data-formats.html
- Markers and pagination: https://xrpl.org/markers-and-pagination.html
- Reliable transaction submission: https://xrpl.org/reliable-transaction-submission.html
For Next Lesson:
You now have the tools to read any data from the XRPL. Lesson 5 will step back to compare XRPL's model with other blockchains, helping you understand when XRPL is the right choice for a project—and when it isn't.
End of Lesson 4
Total words: ~5,100
Estimated completion time: 50 minutes reading + 2-3 hours for deliverable
Key Takeaways
Always use validated ledger for accuracy
: Current ledger is for transaction preparation only. Display validated balances to users to avoid showing unconfirmed state.
Markers are your pagination tool
: Don't try to predict page numbers. Request with marker from previous response until no marker is returned.
Rate limiting is your responsibility
: Public servers have limits. Implement rate limiting proactively; don't wait for errors.
Cache appropriately
: Historical data doesn't change; cache it aggressively. Current state changes constantly; query it fresh.
Parse delivered_amount from metadata
: The transaction Amount field is what was requested; delivered_amount is what actually happened. ---