Implement a new RPC command end-to-end — the handler function, registration and conditions.
What you'll learn
≈60 min · Advanced · builds on RPC authentication & error handling
Time to write something of your own. In this module you'll implement a new RPC command end to end, the handler function, its registration in the handler table with a role and condition, and reading parameters to build a clean response with jss fields. It's the most hands-on module of the RPC phase, and the payoff for everything before it.
In brief: everything a new handler needs before it works end to end.
Before you start coding, here's what you need to prepare:
Define the handler's purpose (What query or operation will it perform? Identify required parameters) What inputs does it need from the client? Determine permission level, What role should be required (USER, ADMIN, etc.)? Specify ledger requirements (Does it need current, validated, or specific ledgers? Plan error scenarios) What could go wrong, and how will you handle it? Design the response format, What data structure will you return?
In brief: write the function, declare it, register it, done.
Create a new file in the handlers directory. Current handler files carry no license banner — they begin directly with their #include lines (open account/AccountInfo.cpp and see). The JSON library lives in the xrpl::json namespace, so inside namespace xrpl you spell it json::Value. Note that RPC::lookupLedger is declared in RPCLedgerHelpers.h, not RPCHelpers.h.
File: src/xrpld/rpc/handlers/MyCustomHandler.cpp
#include <xrpld/app/main/Application.h>
#include <xrpld/rpc/Context.h>
#include <xrpld/rpc/detail/RPCLedgerHelpers.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/jss.h>
namespace xrpl {
// The handler definition goes here (Step 2). Its declaration goes in
// Handlers.h (Step 3), which is what Handler.cpp sees when it builds
// the handler table.
} // namespace xrpl
The build picks up new .cpp files under src/xrpld/ automatically (the sources are globbed in cmake/XrplCore.cmake) — re-run CMake after adding the file.
Key idea. A command only becomes reachable once it is registered in the handler table with its role and condition. Writing the function is not enough.
Implement the handler with the standard signature:
json::Value
doMyCustomCommand(RPC::JsonContext& context)
{
// Step 1: Validate input parameters
if (!context.params.isMember(jss::account))
return RPC::missingFieldError(jss::account);
if (!context.params[jss::account].isString())
return RPC::invalidFieldError(jss::account);
// Step 2: Get the ledger to query. On success, lookupLedger has
// already filled `result` with the ledger context: ledger_index
// (or ledger_current_index) and validated.
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result; // the error from the ledger lookup
// Step 3: Parse and validate the account
auto const account =
parseBase58<AccountID>(context.params[jss::account].asString());
if (!account)
{
RPC::injectError(RpcActMalformed, result);
return result;
}
// Step 4: Query the ledger
auto const sleAccount = ledger->read(keylet::account(*account));
if (!sleAccount)
{
RPC::injectError(RpcActNotFound, result);
return result;
}
// Step 5: Build the response on top of what lookupLedger filled in
result[jss::account] = toBase58(*account);
// Add custom data
result["balance"] = sleAccount->getFieldAmount(sfBalance).getText();
result["sequence"] = sleAccount->getFieldU32(sfSequence);
return result;
}
Two idioms to notice, both lifted straight from doAccountInfo:
RPC:: helpers — RPC::missingFieldError(jss::account), RPC::invalidFieldError(jss::account), RPC::makeError(code, message). The old one-argument rpcError(code) still exists in RPCErr.h but is marked deprecated; it cannot carry a message at all.result that lookupLedger returned — it already contains ledger_index/ledger_current_index and validated, so you never compute "validated" yourself. Once you have a result, report later errors with RPC::injectError(code, result) so the ledger context survives in the error response.This step is not optional. Handler.cpp builds the handler table from names like &doMyCustomCommand, and the only declarations it sees come from #include <xrpld/rpc/handlers/Handlers.h>. Without this declaration, Step 4 does not compile — doMyCustomCommand is an undeclared identifier in Handler.cpp's translation unit. (A forward declaration inside your own .cpp file is invisible to Handler.cpp.)
File: src/xrpld/rpc/handlers/Handlers.h
json::Value
doMyCustomCommand(RPC::JsonContext&);
Add your handler to the central table, kHandlerArray:
File: src/xrpld/rpc/detail/Handler.cpp
{.name = "my_custom_command",
.valueMethod = byRef(&doMyCustomCommand),
.role = Role::USER,
.condition = Condition::NeedsCurrentLedger},
In brief: the object that carries the request, services, and ledger access into your handler.
The JsonContext object is your gateway to Rippled's internals. Here is the real definition, from src/xrpld/rpc/Context.h:
/** The context of information needed to call an RPC. */
struct Context
{
beast::Journal const j;
Application& app;
Resource::Charge& loadType;
NetworkOPs& netOps;
LedgerMaster& ledgerMaster;
Resource::Consumer& consumer;
Role role;
std::shared_ptr<JobQueue::Coro> coro;
InfoSub::pointer infoSub;
unsigned int apiVersion;
};
struct JsonContext : public Context
{
json::Value params; // request parameters from the client
Headers headers{}; // user / forwardedFor from HTTP headers
};
Two things worth calling out:
ledger member. A handler is never handed a ledger — it resolves one itself, via RPC::lookupLedger, from whatever ledger_index / ledger_hash the client sent.loadType is how a handler declares its cost. Expensive handlers set it so the resource manager charges the caller accordingly — doSubmit starts with context.loadType = Resource::kFeeMediumBurdenRpc;.// Get the current (open) ledger
auto currentLedger = context.ledgerMaster.getCurrentLedger();
// Get the server's operating mode as a string ("full", "proposing", ...)
auto serverState = context.netOps.strOperatingMode();
// Access configuration
auto const& config = context.app.config();
// Application implements ServiceRegistry: services are exposed
// through get* accessors
auto& openLedger = context.app.getOpenLedger(); // the open ledger
auto& txQ = context.app.getTxQ(); // the transaction queue
In brief: validate every field the caller sends before you use it.
Proper input validation is critical for security and reliability:
if (!context.params.isMember(jss::account))
return RPC::missingFieldError(jss::account);
if (!context.params[jss::account].isString())
return RPC::invalidFieldError(jss::account);
These helpers (declared in <xrpl/protocol/ErrorCodes.h>) produce the standard messages clients already know — "Missing field 'account'.", "Invalid field 'account'." — so every handler reports the same way.
auto const account = parseBase58<AccountID>(
context.params[jss::account].asString()
);
if (!account)
return RPC::makeError(RpcActMalformed);
if (context.params.isMember(jss::limit)) {
if (!context.params[jss::limit].isUInt())
return RPC::expectedFieldError(jss::limit, "unsigned integer");
unsigned int limit = context.params[jss::limit].asUInt();
if (limit == 0 || limit > 1000)
return RPC::makeParamError("'limit' must be between 1 and 1000");
}
if (context.params.isMember(jss::currency)) {
Currency currency;
if (!toCurrency(currency, context.params[jss::currency].asString()))
return RPC::invalidFieldError(jss::currency);
}
(toCurrency is the current spelling — the old to_currency is gone. The two-argument overload returns bool; the one-argument overload returns a Currency and signals failure with noCurrency().)
Most handlers need to access ledger data:
The standard way to get a ledger (declared in <xrpld/rpc/detail/RPCLedgerHelpers.h>):
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result; // Return the error response
// Now you can safely use 'ledger' — and 'result' already carries
// the ledger context (ledger_index / validated)
This helper function:
ledger_index or ledger_hash from params"validated", "current", "closed"ledger shared pointerjson::Value with the ledger context — jss::ledger_index (or jss::ledger_current_index) and jss::validated — so your handler builds on top of it instead of recomputing those fieldsFor advanced use cases:
std::shared_ptr<ReadView const> ledger;
if (context.params.isMember(jss::ledger_index)) {
auto const ledgerIndex = context.params[jss::ledger_index].asUInt();
ledger = context.ledgerMaster.getLedgerBySeq(ledgerIndex);
} else {
// Default to current ledger
ledger = context.ledgerMaster.getCurrentLedger();
}
if (!ledger)
return RPC::makeError(RpcLgrNotFound);
Once you have a ledger, you can query its state:
auto const sleAccount = ledger->read(keylet::account(accountID));
if (!sleAccount)
return RPC::makeError(RpcActNotFound);
// Access account fields
STAmount balance = sleAccount->getFieldAmount(sfBalance);
std::uint32_t sequence = sleAccount->getFieldU32(sfSequence);
AccountID account = sleAccount->getAccountID(sfAccount);
auto const sleRippleState = ledger->read(
keylet::trustLine(accountID, issuerID, currency)
);
if (sleRippleState) {
STAmount balance = sleRippleState->getFieldAmount(sfBalance);
// A RippleState entry carries one limit per side of the line
// (sfLowLimit and sfHighLimit). Which one is "your" limit depends
// on whether the queried account is the low or the high account:
STAmount lowLimit = sleRippleState->getFieldAmount(sfLowLimit);
STAmount highLimit = sleRippleState->getFieldAmount(sfHighLimit);
bool const viewLowest = (lowLimit.getIssuer() == accountID);
STAmount limit = viewLowest ? lowLimit : highLimit;
}
(This is exactly how TrustLine.cpp does it — it reads both fields and selects by side.)
auto const sleOffer = ledger->read(keylet::offer(accountID, sequence));
if (sleOffer) {
STAmount takerPays = sleOffer->getFieldAmount(sfTakerPays);
STAmount takerGets = sleOffer->getFieldAmount(sfTakerGets);
}
auto const dir = ledger->read(keylet::ownerDir(accountID));
if (dir) {
for (auto const& index : dir->getFieldV256(sfIndexes)) {
auto const sle = ledger->read(keylet::child(index));
// Process each owned object
}
}
Build well-structured JSON responses:
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result;
// lookupLedger already set jss::ledger_index (or ledger_current_index)
// and jss::validated on `result`; add your fields on top of it.
result[jss::account] = toBase58(accountID);
return result;
json::Value accountData(json::ValueType::Object);
accountData[jss::Account] = toBase58(accountID);
accountData["Balance"] = balance.getText();
accountData[jss::Sequence] = sequence;
result[jss::account_data] = accountData;
json::Value lines(json::ValueType::Array);
for (auto const& line : trustLines) {
json::Value lineJson(json::ValueType::Object);
lineJson[jss::account] = to_string(line.account);
lineJson[jss::balance] = line.balance.getText();
lineJson[jss::currency] = to_string(line.currency);
lines.append(lineJson);
}
result[jss::lines] = lines;
(There is no json::arrayValue alias in this tree — spell out json::ValueType::Array and json::ValueType::Object.)
Rippled provides many utility functions to simplify handler implementation:
// From <xrpl/protocol/AccountID.h> — the same template every handler uses
auto account = parseBase58<AccountID>(accountStr);
if (!account)
return RPC::makeError(RpcActMalformed);
// From <xrpl/protocol/STAmount.h>
STAmount amount;
if (!amountFromJsonNoThrow(amount, context.params[jss::amount]))
return RPC::invalidFieldError(jss::amount);
// From <xrpl/protocol/UintTypes.h>
Currency currency;
if (!toCurrency(currency, context.params[jss::currency].asString()))
return RPC::invalidFieldError(jss::currency);
// From <xrpl/protocol/ErrorCodes.h>
return RPC::makeError(RpcLgrNotFound); // code only
return RPC::makeError(RpcNotSupported, "Not supported."); // code + message
return RPC::missingFieldError(jss::account); // "Missing field 'account'."
RPC::injectError(RpcActNotFound, result); // add error to an existing result
Let's implement a handler that returns XRP balance with reserve calculations:
json::Value
doGetAccountBalance(RPC::JsonContext& context)
{
// Validate account parameter
if (!context.params.isMember(jss::account))
return RPC::missingFieldError(jss::account);
auto const account = parseBase58<AccountID>(
context.params[jss::account].asString()
);
if (!account)
return RPC::makeError(RpcActMalformed);
// Get ledger
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result;
// Read account state
auto const sleAccount = ledger->read(keylet::account(*account));
if (!sleAccount)
{
RPC::injectError(RpcActNotFound, result);
return result;
}
// Get balance
STAmount const balance = sleAccount->getFieldAmount(sfBalance);
// Calculate reserves. Fees::accountReserve(ownerCount) returns
// reserve + ownerCount * increment — it already INCLUDES the base
// reserve, so it IS the total reserve. Don't add the base reserve
// to it again.
auto const& fees = ledger->fees();
std::uint32_t const ownerCount = sleAccount->getFieldU32(sfOwnerCount);
XRPAmount const baseReserve = fees.accountReserve(0);
XRPAmount const ownerReserve = ownerCount * fees.increment;
XRPAmount const totalReserve = fees.accountReserve(ownerCount);
// Build response on top of the ledger context lookupLedger filled in
result[jss::account] = toBase58(*account);
result["balance"] = balance.getText();
result["available_balance"] = to_string(balance.xrp() - totalReserve);
result["base_reserve"] = to_string(baseReserve);
result["owner_reserve"] = to_string(ownerReserve);
result["owner_count"] = ownerCount;
return result;
}
Registration (declaration in Handlers.h first, then the table entry):
{.name = "get_account_balance",
.valueMethod = byRef(&doGetAccountBalance),
.role = Role::USER,
.condition = Condition::NeedsCurrentLedger},
Example Request:
{
"method": "get_account_balance",
"params": [{
"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"ledger_index": "validated"
}]
}
Example Response:
{
"result": {
"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"ledger_index": 12345,
"validated": true,
"balance": "1000000000",
"available_balance": "990000000",
"base_reserve": "10000000",
"owner_reserve": "0",
"owner_count": 0
}
}
Implementing custom RPC handlers transforms your understanding of Rippled's architecture into practical skills. By following the standard function signature, validating inputs thoroughly, leveraging helper functions, and building structured responses, you create handlers that integrate seamlessly with the existing codebase. The step-by-step process, from file creation through registration to testing, provides a repeatable pattern for extending Rippled's API capabilities with your own functionality.
This reference guide walks through three RPC handlers from the rippled codebase. The Submit and ServerInfo listings below are the real implementations (Submit trimmed for length, ServerInfo shown in full); the AccountInfo listing is a simplified sketch of the real handler, which additionally supports ident, signer_lists, and queue.
Each example includes:
The AccountInfo handler queries account state and returns detailed information about an account on the ledger. It's one of the most commonly used RPC commands and demonstrates fundamental patterns.
Source: src/xrpld/rpc/handlers/account/AccountInfo.cpp
This is a simplified sketch that keeps the real handler's structure and idioms — open the source file for the full version (account flags, signer lists, queue data):
json::Value
doAccountInfo(RPC::JsonContext& context)
{
auto& params = context.params;
// STEP 1: Validate request has required parameters
// (the real handler also accepts the legacy `ident` alias)
std::string strIdent;
if (params.isMember(jss::account))
{
if (!params[jss::account].isString())
return RPC::invalidFieldError(jss::account);
strIdent = params[jss::account].asString();
}
else
{
return RPC::missingFieldError(jss::account);
}
// STEP 2: Get the ledger to query against. On success, `result`
// already carries the ledger context (ledger_index / validated).
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result; // Return error from ledger lookup
// STEP 3: Parse and validate the account identifier
auto const id = parseBase58<AccountID>(strIdent);
if (!id)
{
RPC::injectError(RpcActMalformed, result);
return result;
}
// STEP 4: Query the account state from the ledger
auto const sleAccepted = ledger->read(keylet::account(*id));
if (!sleAccepted)
{
result[jss::account] = toBase58(*id);
RPC::injectError(RpcActNotFound, result);
return result;
}
// STEP 5: Build the result object
result[jss::account_data] =
sleAccepted->getJson(JsonOptions::Values::None);
return result;
}
1. Parameter Validation
if (!params[jss::account].isString())
return RPC::invalidFieldError(jss::account);
// ...
return RPC::missingFieldError(jss::account);
2. Ledger Lookup Comes First
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result; // Forward the error response
RPC::lookupLedger() to get appropriate ledgerresult is the base of your response — it already carries ledger_index/ledger_current_index and validated3. Account Parsing, Errors Injected Into the Result
auto const id = parseBase58<AccountID>(strIdent);
if (!id)
{
RPC::injectError(RpcActMalformed, result);
return result;
}
parseBase58<>() for Base58Check decodingRPC::injectError adds the error to the existing result, keeping the ledger context in the error response4. Data Query
auto const sleAccepted = ledger->read(keylet::account(*id));
if (!sleAccepted)
{
result[jss::account] = toBase58(*id);
RPC::injectError(RpcActNotFound, result);
return result;
}
5. Response Construction
result[jss::account_data] =
sleAccepted->getJson(JsonOptions::Values::None);
return result;
getJson(JsonOptions::Values::None) (house style spells the option out)result from lookupLedger| Error Condition | Error Code | Message |
|---|---|---|
| Missing account parameter | RpcInvalidParams | "Missing field 'account'." |
| Malformed account address | RpcActMalformed | "Account malformed." |
| Ledger not found | RpcLgrNotFound | "Ledger not found." |
| Account doesn't exist | RpcActNotFound | "Account not found." |
The Submit handler processes transaction submission. It demonstrates more complex patterns including resource charging, deserialization, local validity checks, and handing the transaction to the network layer.
Source: src/xrpld/rpc/handlers/transaction/Submit.cpp
Note the registration first — submit is a Role::USER command; any client can submit a pre-signed transaction:
{.name = "submit",
.valueMethod = byRef(&doSubmit),
.role = Role::USER,
.condition = Condition::NeedsCurrentLedger},
This is the real doSubmit, trimmed for length:
json::Value
doSubmit(RPC::JsonContext& context)
{
// STEP 1: Declare this handler's cost to the resource manager
context.loadType = Resource::kFeeMediumBurdenRpc;
if (!context.params.isMember(jss::tx_blob))
{
// Deprecated sign-and-submit path (tx_json + secret). Signing
// for clients is only allowed for admins, or when the server
// is explicitly configured to sign.
if (context.role != Role::ADMIN && !context.app.config().canSign())
return RPC::makeError(
RpcNotSupported, "Signing is not supported by this server.");
auto ret = RPC::transactionSubmit(/* ... signs, then submits ... */);
ret[jss::deprecated] =
"Signing support in the 'submit' command has been "
"deprecated ...";
return ret;
}
json::Value jvResult;
// STEP 2: Decode the hex tx_blob
auto ret = strUnHex(context.params[jss::tx_blob].asString());
if (!ret || ret->empty())
return rpcError(RpcInvalidParams);
// STEP 3: Deserialize into an STTx
SerialIter sitTrans(makeSlice(*ret));
std::shared_ptr<STTx const> stTx;
try
{
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
}
catch (std::exception& e)
{
jvResult[jss::error] = "invalidTransaction";
jvResult[jss::error_exception] = e.what();
return jvResult;
}
// STEP 4: Run local validity checks (signature, current rules)
auto [validity, reason] = checkValidity(
context.app.getHashRouter(),
*stTx,
context.ledgerMaster.getCurrentLedger()->rules());
if (validity != Validity::Valid)
{
jvResult[jss::error] = "invalidTransaction";
jvResult[jss::error_exception] = "fails local checks: " + reason;
return jvResult;
}
// STEP 5: Wrap it and hand it to the network layer
std::string reason;
auto transaction = std::make_shared<Transaction>(stTx, reason, context.app);
// ...
context.netOps.processTransaction(
transaction, isUnlimited(context.role), true, *failType);
// STEP 6: Build the result from the engine's verdict
jvResult[jss::tx_json] = transaction->getJson(JsonOptions::Values::None);
jvResult[jss::tx_blob] =
strHex(transaction->getSTransaction()->getSerializer().peekData());
// engine_result / engine_result_code / engine_result_message,
// plus accepted / applied / broadcast / queued / kept ...
return jvResult;
}
1. Resource Charging
context.loadType = Resource::kFeeMediumBurdenRpc;
context.loadType first, before doing any work2. Binary Deserialization
auto ret = strUnHex(context.params[jss::tx_blob].asString());
SerialIter sitTrans(makeSlice(*ret));
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
strUnHex, then deserialize with SerialIter3. Context-Dependent Validation
auto [validity, reason] = checkValidity(
context.app.getHashRouter(), *stTx,
context.ledgerMaster.getCurrentLedger()->rules());
4. Role-Sensitive Behavior, Not Role Gating
context.netOps.processTransaction(
transaction, isUnlimited(context.role), true, *failType);
Role::USER — there is no role gate on submissionisUnlimited(context.role) only relaxes rate limits for trusted callerscontext.role != Role::ADMIN && !context.app.config().canSign())| Scenario | Code / error | Response |
|---|---|---|
| Undecodable tx_blob | RpcInvalidParams | 200 OK, error object in the body |
| Unparseable transaction | "invalidTransaction" + exception text | 200 OK, error object in the body |
| Fails local checks | "invalidTransaction" + reason | 200 OK, error object in the body |
| Signing requested, not supported | RpcNotSupported | 200 OK, error object in the body |
Note: JSON-RPC over HTTP in rippled returns HTTP 200 with the error inside the JSON body for handler-level failures; non-200 statuses are reserved for transport-level problems (malformed HTTP, unauthorized port, server overload).
The ServerInfo handler returns comprehensive information about the running node. It demonstrates the opposite extreme from Submit: the handler itself is tiny, because all the work is delegated to NetworkOPs.
Source: src/xrpld/rpc/handlers/server_info/ServerInfo.cpp
This is the entire handler, verbatim:
json::Value
doServerInfo(RPC::JsonContext& context)
{
json::Value ret(json::ValueType::Object);
ret[jss::info] = context.netOps.getServerInfo(
true,
context.role == Role::ADMIN,
context.params.isMember(jss::counters) && context.params[jss::counters].asBool());
return ret;
}
NetworkOPs::getServerInfo(bool human, bool admin, bool counters) assembles the whole info object — server state, validated ledger, peer count, load, and (for admins) extra detail.
1. Delegate Aggregation to the Service That Owns the Data
ret[jss::info] = context.netOps.getServerInfo(true, /* admin */ ..., /* counters */ ...);
NetworkOPs owns that data and builds the JSONdoServerState (the machine-readable sibling) calls the same function with human = false2. Role-Based Response Modification
context.role == Role::ADMIN
admin flag; getServerInfo includes sensitive detail only when it is true3. Optional Request Flags
context.params.isMember(jss::counters) && context.params[jss::counters].asBool()
Notably, ServerInfo has no error paths because:
getServerInfo by omitting fieldscounters) is an optional flag read defensivelyThis demonstrates that not all handlers need error-heavy validation. Only validate what the client provides.
// Safe extraction with validation
if (!context.params.isMember(jss::account))
return RPC::missingFieldError(jss::account);
std::string const strAccount =
context.params[jss::account].asString();
auto const account = parseBase58<AccountID>(strAccount);
if (!account)
return RPC::makeError(RpcActMalformed);
// Get appropriate ledger
std::shared_ptr<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result; // Propagate error
// Use ledger safely
auto const sle = ledger->read(someKeylet);
if (!sle)
{
RPC::injectError(RpcActNotFound, result);
return result;
}
// Check permission early
if (context.role != Role::ADMIN)
return RPC::makeError(RpcNoPermission);
// Now proceed with privileged operations
// Start from the result lookupLedger returned — it already carries
// jss::ledger_index (or ledger_current_index) and jss::validated
auto result = RPC::lookupLedger(ledger, context);
if (!ledger)
return result;
// Add required fields
result[jss::field1] = value1;
// Add optional fields conditionally
if (condition)
result[jss::optional_field] = optionalValue;
return result;
// BAD: Multiple reads from ledger
auto sle1 = ledger->read(keylet1);
auto sle2 = ledger->read(keylet2);
// GOOD: Batch when possible, cache results
// Handlers run on the JobQueue's RPC job threads — a slow handler
// occupies one of them. Declare the cost so the resource manager
// charges (and eventually throttles) the caller:
context.loadType = Resource::kFeeMediumBurdenRpc;
Long-running handlers can also run as coroutines (context.coro; the JobQueue side is postCoro), which is how commands like path finding avoid pinning a job thread.
// Check before expensive operations
if (!isUnlimited(context.role)) {
return RPC::makeError(RpcNoPermission); // Fast fail
}
// Now do expensive work
See Testing RPC Handlers for comprehensive testing strategies.
RPC handlers are not tested by constructing a JsonContext by hand — it's a bundle of live references (Application&, NetworkOPs&, LedgerMaster&, ...) that can't be defaulted. Real handler tests are beast::unit_test suites that spin up a test Env and call the command through the client interface, like src/test/rpc/AccountInfo_test.cpp:
class MyCustomCommand_test : public beast::unit_test::Suite
{
public:
void
testValidAccount()
{
testcase("Valid account");
using namespace jtx;
Env env(*this);
Account const alice{"alice"};
env.fund(XRP(10000), alice);
env.close();
json::Value params;
params[jss::account] = alice.human();
auto const info =
env.rpc("json", "my_custom_command", to_string(params));
BEAST_EXPECT(info[jss::result][jss::status] == "success");
BEAST_EXPECT(info[jss::result].isMember(jss::account));
}
void
run() override
{
testValidAccount();
}
};
BEAST_DEFINE_TESTSUITE(MyCustomCommand, rpc, xrpl);
Related Module Sections:
This module had you build an RPC command end to end. You wrote a handler with the standard signature, declared it in Handlers.h, registered it in the handler table with a role and condition, validated the incoming parameters, looked up the ledger, and built a clean JSON response using jss field names. A command only becomes reachable once it is registered; writing the function alone is not enough.
To remember:
doMyCommand(RPC::JsonContext&) returning json::Value, declare it in Handlers.h (required — Handler.cpp compiles against that header), register it in kHandlerArray (role + condition)parseBase58<AccountID> for accounts)RPC::lookupLedger to resolve ledger_index / ledger_hash arguments safely — and build your response on the result it returns, which already carries ledger_index and validatedview->read(...)jss:: field constantsserver_info/ServerInfo.cpp, account/AccountInfo.cpp in src/xrpld/rpc/handlersRPC::makeError / missingFieldError / invalidFieldError / injectError helpers, never ad-hoc strings; clients dispatch on the code (the one-argument rpcError is deprecated)Next up. It works! Now make it something a reviewer would sign off. Next: the best practices that separate working handlers from production handlers.
Resources
Assignments
0 of 2 complete