Patterns and helpers for robust, consistent RPC handlers.
What you'll learn
≈60 min · Advanced · builds on Building a custom RPC handler
A working handler and a good handler are not the same thing. In this module you'll pick up the patterns that separate them: rigorous input validation, safe ledger access, reusing helpers like lookupLedger and parseBase58, and the pitfalls (trusting client input, leaking internals, silent failures) that catch newcomers. Write handlers other people can trust.
In brief: naming and formatting conventions that keep handlers consistent.
| Element | Convention | Example |
|---|---|---|
| Handler functions | doPascalCase |
doAccountInfo(RPC::JsonContext&) |
| Files | PascalCase.cpp under src/xrpld/rpc/handlers |
AccountInfo.cpp |
| Helpers | camelCase |
validateAccountAddress() |
| Member variables | trailing underscore, is prefix for bools |
ledger_, isValid_ |
| Constants | UPPER_CASE, constexpr |
MAX_PAGE_SIZE = 1000 |
| Indentation | 4 spaces, Stroustrup braces, ~80 char lines |
Structure every handler in the same visible order, separated by blank lines: input validation, then main logic, then response building. Reviewers read dozens of handlers; sameness is a feature.
In brief: the mistakes that bite new handler authors most.
| # | Pitfall | The fix in one line |
|---|---|---|
| 1 | trusting client input | validate presence and type before every .asString() |
| 2 | not checking ledger availability | test the lookupLedger result before reading |
| 3 | exposing implementation details | log the real error, return a generic one |
| 4 | ignoring resource limits | cap every loop and every result set |
| 5 | unguarded shared state | mutex or thread-local; handlers run concurrently |
| 6 | bare error codes | always add the human-readable message |
| 7 | silent failures | every error path returns an error object |
| 8 | missing permission checks | check context.role before doing anything sensitive |
The three that cause real incidents, in code:
// Pitfall 1: trusting client input
std::string account = context.params["account"].asString(); // BAD: crashes if absent
if (!context.params.isMember("account") ||
!context.params["account"].isString()) // GOOD
return rpcError(RpcInvalidParams, "Missing or invalid 'account'");
// Pitfall 2: unchecked ledger access
auto entry = context.ledger->read(k); // BAD: context.ledger may be null
if (!context.ledger) // GOOD
return rpcError(RpcNoCurrent);
auto entry = context.ledger->read(k);
if (!entry)
return rpcError(RpcActNotFound);
// Pitfall 3: leaking internals
return rpcError(RpcInternal,
"SQL query failed: " + std::string(ex.what())); // BAD: leaks schema details
JLOG(context.app.journal("RPC")) << "DB error: " << ex.what(); // GOOD: log it...
return rpcError(RpcInternal); // ...return generic
Watch out. Never trust client input, always check ledger availability before reading, and never leak internal details in error messages. Most handler bugs trace back to one of these three.
In brief: how to keep a handler fast under load.
| Lever | Bad habit | Good habit |
|---|---|---|
| Ledger access | calling lookupLedger repeatedly |
resolve once, reuse the ReadView |
| Queries | one read per item, unbounded | batch reads, cap the loop |
| Memory | materialise a million-item array | paginate with limit + marker |
| Strings | chained += concatenations |
std::stringstream or fmt::format |
The single most common offence, spelled out:
std::shared_ptr<ReadView const> ledger;
auto const result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result;
// reuse 'ledger' for every read in this handler; never look it up twice
Document each handler in its header: purpose, required role and conditions, each parameter, response fields, and the error codes it can return. Keep it accurate; a stale doc is worse than none.
/**
* account_tx_summary: paginated transaction history for an account.
*
* Role: USER. Conditions: NeedsCurrentLedger.
* Params: account (required, base58), ledger_index (optional),
* limit (optional, 1-1000, default 100), marker (optional).
* Errors: RpcInvalidParams, RpcActMalformed, RpcActNotFound, RpcNoCurrent.
*/
Json::Value doAccountTxSummary(RPC::JsonContext& context);
Maintenance habits that pay off:
1..1 for the old handler, 2..UINT_MAX for the new one) instead of mutating behaviour under callers."deprecated": true plus "use_instead" to its response.In brief: what to check before a handler ships.
| Area | Verify |
|---|---|
| Functionality | all parameters validated; every error path returns the right code; response matches the spec |
| Security | no secrets; no internal details in responses; role checks present; resource limits enforced |
| Performance | single ledger lookup; large results paginated; no unbounded memory |
| Maintainability | naming conventions; small functions; no duplicated logic |
| Documentation | header comment covers params, errors, role; complex sections commented |
| Testing | happy path, every error code, every role, edge cases |
One handler, read in four passes. Together the four fences form the complete doAccountTransactions.
Pass 1: validate the input. Presence, type, bounds; fail fast with a specific message.
Json::Value doAccountTransactions(RPC::JsonContext& context)
{
if (!context.params.isMember(jss::account))
return rpcError(RpcInvalidParams, "Missing 'account' field");
auto const account = parseBase58<AccountID>(
context.params[jss::account].asString());
if (!account)
return rpcError(RpcActMalformed, "Invalid account address");
Pass 2: clamp the pagination. The client proposes, the server decides.
unsigned int pageSize = 100;
if (context.params.isMember("limit")) {
if (!context.params["limit"].isUInt())
return rpcError(RpcInvalidParams, "'limit' must be a positive integer");
pageSize = context.params["limit"].asUInt();
if (pageSize < 1 || pageSize > 1000)
return rpcError(RpcInvalidParams, "'limit' must be between 1 and 1000");
}
Pass 3: resolve the ledger once, then query with a bound.
std::shared_ptr<ReadView const> ledger;
auto const ledgerResult = RPC::lookupLedger(ledger, context);
if (!ledger)
return ledgerResult;
std::vector<Json::Value> transactions;
std::string nextMarker;
for (auto seq = ledger->info().seq;
seq > 0 && transactions.size() < pageSize; --seq) {
// fetch ledger seq, filter by account, append...
}
Pass 4: build the response, marker last.
Json::Value result;
result[jss::status] = jss::success;
result[jss::account] = to_string(*account);
result[jss::ledger_index] = ledger->info().seq;
result[jss::validated] = ledger->isImmutable();
result["transactions"] = txArray;
if (!nextMarker.empty())
result["marker"] = nextMarker; // absent marker = last page
return result;
}
In brief: rippled already wrote the utilities; use them instead of reinventing.
Everything below lives in src/xrpld/rpc/detail/RPCHelpers.h / .cpp unless noted.
| Task | Helper | Returns / notes |
|---|---|---|
| Parse an account address | RPC::accountFromString(str) |
optional<AccountID>; wraps parseBase58 |
| Parse any Base58Check value | parseBase58<T>(str) |
optional<T>; checksum verified; works for AccountID, uint256, seeds |
| Resolve the requested ledger | RPC::lookupLedger(ledger, context) |
fills the out-param; returns the error JSON on failure; handles ledger_hash, ledger_index, "current", "validated" |
| Validate a ledger index range | RPC::getLedgerRange(min, max, context) |
false if no ledgers; clamp user indices against it |
| Parse an amount (XRP or IOU) | amountFromJsonNoThrow(value) |
optional<Amount>; accepts drops string/number or currency object |
| Convert types for JSON output | to_string(hash / account / type) |
overloaded for most protocol types |
| Check the caller's privileges | context.role is a plain Role enum: compare with Role::ADMIN, or call the free function isUnlimited(context.role) (true for ADMIN and IDENTIFIED) |
check early, before expensive work |
| Read a ledger entry | ledger->read(keylet::account(id)) |
nullptr = not found; keylets exist for accounts, offers, trust lines, escrows... |
| Build an error response | rpcError(code, message) |
standard JSON-RPC error shape; never craft error JSON by hand |
lookupLedger failure modes worth knowing: RpcLgrNotFound (bad hash/index), RpcNoCurrent / RpcNoClosed (node not ready), RpcInvalidLgrRange.
Every helper in its place; this is the shape reviewers expect:
Json::Value doMyHandler(RPC::JsonContext& context)
{
if (!isUnlimited(context.role)) // 1. permissions first
return rpcError(RpcNoPermission); // (Role.h free function)
auto const account = RPC::accountFromString( // 2. validate input
context.params[jss::account].asString());
if (!account)
return rpcError(RpcActMalformed);
std::shared_ptr<ReadView const> ledger; // 3. resolve the ledger
auto const result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result;
auto const sle = ledger->read( // 4. query
keylet::account(*account));
if (!sle)
return rpcError(RpcActNotFound);
Json::Value response; // 5. respond
response[jss::account] = to_string(*account);
response[jss::balance] = to_string(sle->getFieldAmount(sfBalance));
return response;
}
This module covered the patterns that separate a working handler from a good one: rigorous input validation, checking ledger availability before reading, reusing helpers like lookupLedger and parseBase58, and returning complete, honest error responses. It also walked the common pitfalls, trusting client input, leaking internal details, and silent failures, that catch newcomers.
To remember:
lookupLedger result) before reading anythinglookupLedger, parseBase58, accountFromStringjss::, response shapeNext up. Clean code still needs proof. Next: testing your handler with the same framework rippled tests itself with.
Resources
Assignments
0 of 2 complete