How to test RPC handlers with the jtx framework and debug them.
What you'll learn
≈60 min · Advanced · builds on RPC handler best practices
Code you can't test is code you can't trust. In this module you'll learn to test a handler with the jtx framework and its Env (covering happy paths, error conditions, input validation and role checks) and to debug it with logging and gdb when something's off. This is how your custom command becomes production-ready.
In brief: beast::unit_test + the jtx Env, the exact shape of every suite in src/test/rpc.
Rippled uses its own beast::unit_test framework (not Google Test or Catch2), with the jtx helpers on top. A suite is a class, each scenario is a testcase, and Env gives every test a funded, throwaway ledger:
#include <test/jtx.h>
class MyHandler_test : public beast::unit_test::Suite
{
public:
void
testBasics()
{
testcase("Basics");
using namespace jtx;
Env env(*this); // disposable ledger
Account const alice{"alice"}; // named test account
env.fund(XRP(1000), alice); // create + fund it
env.close(); // close the ledger
}
void
run() override
{
testBasics();
}
};
BEAST_DEFINE_TESTSUITE(MyHandler, rpc, xrpl);
Key idea. The jtx
Envgives you a throwaway ledger to submit transactions against, so you test a handler's real behaviour rather than a mock of it.
There is no mocking framework in rippled's tests: you stage the situation you need through Env itself (fund or don't fund accounts, close or don't close ledgers, toggle amendments with Env env(*this, testable_amendments() - featureX)). Real state beats mocks here.
Every scenario follows Arrange / Act / Assert. You call the handler the way a client would, through env.rpc, and assert on the JSON with BEAST_EXPECT:
void
testHappyPath()
{
testcase("Happy path");
using namespace jtx;
Env env(*this);
Account const alice{"alice"};
env.fund(XRP(1000), alice); // Arrange
env.close();
json::Value params;
params[jss::account] = alice.human();
auto const info = env.rpc( // Act
"json", "account_info", to_string(params));
BEAST_EXPECT( // Assert
info[jss::result][jss::account_data][jss::Balance] == "1000000000");
}
Name testcases after the behaviour they pin down ("Errors", "Signer lists", "Malformed account"); never "Test1" or "Works".
In brief: happy path, error paths, input validation, roles; every handler needs all four.
The ReturnsBalance test above is the model: real accounts, real transactions, assert on the exact JSON. Add one variant per meaningful option (ledger_index numeric vs "validated", optional fields present or absent).
One test per error code the handler can return. The shape never changes; only the arrangement and the expected code do:
{
// straight from src/test/rpc/AccountInfo_test.cpp
auto const info = env.rpc("json", "account_info",
R"({"account": "not-a-valid-address"})");
BEAST_EXPECT(info[jss::result][jss::error_code] == RpcActMalformed);
BEAST_EXPECT(info[jss::result][jss::error_message] == "Account malformed.");
}
| Arrangement | Expected error |
|---|---|
| required parameter missing | RpcInvalidParams |
| account string malformed | RpcActMalformed |
| account absent from the ledger | RpcActNotFound |
ledger_index = 999999999 |
RpcLgrNotFound |
| no current ledger (mocked) | RpcNoCurrent |
Same pattern, focused on each parameter's type and bounds. All of these expect RpcInvalidParams:
| Parameter | Invalid input to test |
|---|---|
limit |
"not-a-number", 0, 2000 (above the cap) |
currency |
over-long or non-standard code |
amount |
malformed JSON amount |
| optional fields | present but invalid (they are not skipped) |
Build the same request with different roles and assert what each may see or do:
{
// admin-only command through a non-admin connection
auto const res = env.rpc("json", "my_admin_command", "{ }");
BEAST_EXPECT(res[jss::result][jss::error_code] == RpcNoPermission);
}
Env runs with admin rights by default; to exercise the non-admin path, construct it with a config whose port grants no admin access (envconfig(no_admin)), then run the same commands.
| Role | Expectation |
|---|---|
GUEST |
public queries succeed; privileged commands return RpcNoPermission |
USER |
standard operations succeed |
ADMIN |
admin-only response fields are present |
Empty strings, zero and huge balances, limit at exactly its minimum and maximum, special ledger indices ("validated", "current"), and a burst of concurrent requests against the same handler (thread safety).
Unit tests exercise one handler; an integration test walks a real flow end to end:
void
testTransactionQueryFlow()
{
testcase("Payment then query");
using namespace jtx;
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
env.fund(XRP(1000), alice, bob);
env.close();
env(pay(alice, bob, XRP(100))); // a real payment
env.close();
json::Value params;
params[jss::account] = alice.human();
auto const info = env.rpc("json", "account_info", to_string(params));
BEAST_EXPECT( // sequence advanced
info[jss::result][jss::account_data][jss::Sequence].asUInt() > 1);
}
You can also drive a live standalone node from the outside: start ./xrpld --standalone, curl the JSON-RPC port, and watch the logs; that is the closest thing to production behaviour.
# Run a specific suite, fast and local (name from BEAST_DEFINE_TESTSUITE)
./xrpld --unittest=MyHandler
# Several suites, or parallel execution
./xrpld --unittest=beast,MyHandler --unittest-jobs=4
# Coverage build + report
cmake -DCMAKE_BUILD_TYPE=Debug -DCOVERAGE=ON ..
lcov --capture --directory . --output-file coverage.info
genhtml coverage.info --output-directory coverage_html
Aim for at least 80% coverage, and make sure the covered lines include every error branch and every role branch, not just the happy path.
| Do | Don't |
|---|---|
| test every error code and every role | test only the happy path |
| use descriptive test names | test implementation internals |
| use jtx for realistic ledger state | hardcode magic test data everywhere |
| add a regression test for every bug found | rely on manual testing |
| test boundaries (min and max) | ignore resource limits and DoS paths |
There is no "complete example" to copy here on purpose: assemble the fixture, the four axes, and the integration flow above, and compare with the real suites in src/test/rpc of the rippled repository; they follow exactly this shape.
Even with careful coding, issues arise. The key is isolating the problem quickly: compile-time, crash, wrong result, or slow.
| Error message | Cause | Fix |
|---|---|---|
'jss::my_field' was not declared |
string constant missing | use constants from xrpl/protocol/jss.h; add yours there if new |
no matching function for call to 'rpcError' |
wrong parameter types | return rpcError(RpcInvalidParams, "msg"); and return it directly |
invalid conversion from 'const AccountID*' |
const dropped | keep const& in signatures; never const_cast |
'parseBase58' was not declared |
missing include | #include <xrpld/rpc/detail/RPCHelpers.h> (plus Context.h, ErrorCodes.h, jss.h) |
Json::Value doMyHandler(RPC::JsonContext& context)
{
JLOG(context.app.journal("RPC").debug())
<< "doMyHandler params: " << context.params.toStyledString();
if (!account) {
JLOG(context.app.journal("RPC").warning())
<< "Failed to parse account";
return rpcError(RpcActMalformed);
}
// ...
}
Journal levels, from loudest to quietest: fatal (node stopping), error (handler failed), warning (recoverable surprise), info, debug (hidden in release builds), trace.
cmake -DCMAKE_BUILD_TYPE=Debug .. # build with symbols
gdb --args ./xrpld --standalone
| Command | Purpose |
|---|---|
break doMyHandler |
stop at handler entry |
break MyHandler.cpp:45 if seq == 3 |
conditional breakpoint |
print context.params / print *account |
inspect values |
next / step / continue |
step over / into / resume |
backtrace, frame 0 |
locate a crash |
x/32bx account |
raw memory in hex |
On macOS, lldb offers the same workflow (br set -f MyHandler.cpp -l 45, p context.params, bt).
Almost every handler segfault is one of these two:
// 1. Unchecked ledger read
auto const sle = ledger->read(keylet);
auto value = sle->getFieldU32(sfBalance); // CRASH if sle is null
// 2. Dereferencing an empty optional
auto const acct = parseBase58<AccountID>(str);
auto const k = keylet::account(*acct); // CRASH if parse failed
The fix is the same shape both times: check, and return the matching rpcError (RpcActNotFound, RpcActMalformed) before dereferencing. For a post-mortem: ulimit -c unlimited, then open the core dump with lldb ./xrpld -c core and read bt.
There is no [logging] config section; run log_level commands at startup via [rpc_startup], one JSON object per line:
[rpc_startup]
{ "command": "log_level", "severity": "info" }
{ "command": "log_level", "partition": "RPCHandler", "severity": "debug" }
At runtime: xrpld log_level RPCHandler debug. Useful partitions: rpc, ledger, transaction, peer.
Bracket the suspect sections with timing logs and read which step dominates:
auto start = std::chrono::high_resolution_clock::now();
// ... handler section ...
JLOG(journal.debug()) << "section took "
<< std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::high_resolution_clock::now() - start).count() << "ms";
Then apply the usual levers: fewer ledger reads, indexed lookups, cache what repeats, and stream large results instead of materialising a giant vector.
When your handler isn't working:
This module covered how to test a handler with the jtx framework and its Env, which gives you a throwaway ledger to submit real transactions against. You learned to cover the happy path, error conditions, input validation, and role-based permissions, and to debug a handler with logging and gdb when something is off. This is how a custom command becomes production-ready.
To remember:
src/test/jtx) gives you Env: a throwaway ledger to test againstenv.fund(...), env(pay(alice, bob, XRP(100))), env.close()./xrpld --unittest="MySuite"Next up. Request and response is mastered. Next, the features behind live explorers and wallets: subscriptions, streaming, and gRPC.
Resources
Assignments
0 of 2 complete