How rippled's RPC layer dispatches JSON-RPC / WebSocket / gRPC requests to handlers.
What you'll learn
≈45 min · Advanced · builds on Handshake & message relaying
This phase is about how the outside world talks to a node. In this module you'll learn how rippled's RPC layer dispatches JSON-RPC, WebSocket and gRPC requests to the right handler: the central handler table, the JsonContext a handler receives, and the path a request takes from transport to response. It's the groundwork for building your own command a couple of modules from now.
In brief: the pieces that make RPC work: the handler table, the handler signature, and the JsonContext.
The RPC system consists of several key components that work together to process requests:
The handler table is a centralized registry that maps RPC command names to their corresponding handler functions.
Location: src/xrpld/rpc/detail/Handler.cpp
// Handler table structure (simplified)
// Simplified model. The real table is a constexpr array, kHandlerArray,
// in src/xrpld/rpc/detail/Handler.cpp, indexed with API version ranges.
std::map<std::string, HandlerInfo> handlerTable = {
{"account_info", {&doAccountInfo, Role::USER, Condition::NeedsCurrentLedger}},
{"ledger", {&doLedger, Role::USER, Condition::NeedsNetworkConnection}},
{"submit", {&doSubmit, Role::USER, Condition::NeedsCurrentLedger}},
// ... hundreds of other handlers
};
Key characteristics:
"account_info")Each handler is described by a HandlerInfo structure containing metadata:
struct HandlerInfo {
handler_type handler; // Function pointer
Role role; // Minimum role required
RPC::Condition condition; // Execution conditions
unsigned int version_min = 1; // Minimum API version
unsigned int version_max = UINT_MAX; // Maximum API version
};
Purpose:
All RPC handlers follow a standardized function signature:
Json::Value handlerName(RPC::JsonContext& context);
Components:
Json::Value, The JSON response objectRPC::JsonContext&, Contains request data, ledger access, and configurationThis consistency allows the dispatcher to invoke any handler uniformly.
The JsonContext provides handlers with everything needed to process a request:
struct JsonContext {
Json::Value params; // Request parameters
Application& app; // Access to application services
Resource::Consumer& consumer; // Resource tracking
Role role; // Caller's permission level
std::shared_ptr<ReadView const> ledger; // Ledger view
NetworkOPs& netOps; // Network operations
LedgerMaster& ledgerMaster; // Ledger management
// ... additional context
};
Key capabilities:
In brief: how a command is added to the central handler table.
Handlers are registered at compile time through static initialization:
// src/xrpld/rpc/handlers/MyCustomHandler.cpp
namespace xrpl {
Json::Value doMyCustomCommand(RPC::JsonContext& context)
{
Json::Value result;
// Implementation here
return result;
}
} // namespace xrpl
// src/xrpld/rpc/detail/Handler.cpp — add an entry to kHandlerArray[]
{.name = "my_custom_command",
.valueMethod = byRef(&doMyCustomCommand), // wrap the handler function
.role = Role::USER, // Minimum role
.condition = Condition::NeedsCurrentLedger}
// src/xrpld/rpc/handlers/Handlers.h
Json::Value doMyCustomCommand(RPC::JsonContext&);
In brief: how an incoming request is matched to its handler and run.
When a client sends an RPC request, the system follows this flow:
The server receives a JSON-RPC request via HTTP, WebSocket, or gRPC:
{
"method": "account_info",
"params": [{
"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs"
}]
}
The dispatcher searches the handler table:
auto it = handlerTable.find(request["method"].asString());
if (it == handlerTable.end()) {
return rpcError(RpcUnknownCommand);
}
Before invoking the handler, the system verifies the caller's role:
if (context.role < handlerInfo.role) {
return rpcError(RpcNoPermission);
}
The system ensures required conditions are met:
if (handlerInfo.condition & Condition::NeedsCurrentLedger) {
if (!context.ledgerMaster.haveLedger()) {
return rpcError(RpcNoCurrent);
}
}
Finally, the handler is executed:
Json::Value response = handlerInfo.handler(context);
The result is wrapped in a JSON-RPC response envelope and returned to the client.
Key idea. Dispatch always runs the same gauntlet: look up the command, check the caller's role, validate conditions, then invoke the handler. Every command goes through it.
In brief: the conditions a handler declares before it may run.
Handlers can declare various capability requirements:
| Flag | Meaning | Example Use Case |
|---|---|---|
NeedsCurrentLedger |
Requires an open (current) ledger | Real-time account queries |
NeedsClosedLedger |
Requires a validated ledger | Historical transaction lookups |
NeedsNetworkConnection |
Requires peer connectivity | Transaction submission |
NoCondition |
No special requirements | Server info, ping |
Example:
{"submit", {&doSubmit, Role::USER, Condition::NeedsCurrentLedger | Condition::NeedsNetworkConnection}}
This ensures the handler cannot execute unless both conditions are satisfied.
In brief: the same handlers serve JSON-RPC, WebSocket, and gRPC.
Rippled's RPC system abstracts away transport details, allowing handlers to work across:
curl -X POST http://localhost:5005/ \
-H "Content-Type: application/json" \
-d '{
"method": "account_info",
"params": [{"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs"}]
}'
const ws = new WebSocket('ws://localhost:6006');
ws.send(JSON.stringify({
command: 'account_info',
account: 'rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs'
}));
service XRPLedgerAPIService {
rpc GetAccountInfo(GetAccountInfoRequest) returns (GetAccountInfoResponse);
}
Handler Transparency: The same handler function serves all three transports, the dispatcher handles protocol-specific details.
In brief: how one command name serves several API versions.
Rippled supports API versioning to maintain backward compatibility:
{
"account_info",
{
&doAccountInfo_v2, // New implementation
Role::USER,
Condition::NeedsCurrentLedger,
2, // Minimum API version
UINT_MAX
}
}
Clients can specify the API version in their requests:
{
"method": "account_info",
"api_version": 2,
"params": [...]
}
If no version is specified, the system uses the default (version 1) implementation.
In brief: the theory applied to one real handler.
Let's examine the registration of the widely-used account_info handler:
Registration (Handlers.cpp):
{
"account_info",
{
&doAccountInfo,
Role::USER,
Condition::NeedsCurrentLedger
}
}
Analysis:
"account_info"doAccountInfo (defined in src/xrpld/rpc/handlers/account/AccountInfo.cpp)USER, Available to authenticated users (not just admins)NeedsCurrentLedger, Requires access to the current open ledgerThis registration tells Rippled:
method: "account_info"doAccountInfo() with the request contextThe RPC handler architecture provides a clean, extensible foundation for Rippled's API layer. Through centralized registration in a handler table, uniform function signatures, and automatic permission enforcement, the system ensures consistency across hundreds of commands while remaining easy to extend. The separation between transport protocols and handler logic means the same implementation serves HTTP, WebSocket, and gRPC clients transparently. Understanding this architecture is essential for navigating the codebase, debugging RPC issues, and preparing to implement custom handlers.
In brief: the twelve stages every request passes, at a glance.
Understanding the complete lifecycle of an RPC request, from the moment it arrives at the server to when the response is sent back to the client, is essential for building robust custom handlers. This knowledge helps you anticipate edge cases, implement proper error handling, and optimize performance.
In this section, we'll trace the journey of a request through Rippled's RPC system, examining each stage of processing and the components involved.
In brief: the same twelve stages, one by one.
Client → Transport Layer → Parser → Validator → Auth → Dispatcher → Handler → Response Builder → Client
Let's break down each stage in detail.
For HTTP requests, the entry point is the HTTP server configured in xrpld.cfg:
[port_rpc_admin_local]
port = 5005 # local example; public servers conventionally expose 51234 (JSON-RPC) / 51233 (WebSocket)
ip = 127.0.0.1
admin = 127.0.0.1
protocol = http
Source Location: src/xrpld/rpc/detail/RPCCall.cpp
The HTTP server receives the raw request:
POST / HTTP/1.1
Host: localhost:5005
Content-Type: application/json
{
"method": "account_info",
"params": [{
"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"ledger_index": "validated"
}]
}
For WebSocket connections, clients establish a persistent connection:
[port_ws_admin_local]
port = 6006
ip = 127.0.0.1
admin = 127.0.0.1
protocol = ws
Source Location: src/xrpld/rpc/detail/RPCHandler.cpp
WebSocket messages use a slightly different format:
{
"id": 1,
"command": "account_info",
"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"ledger_index": "validated"
}
For gRPC, requests arrive as Protocol Buffer messages:
Source Location: src/xrpld/app/main/GRPCServer.cpp
message GetAccountInfoRequest {
string account = 1;
LedgerSpecifier ledger = 2;
}
The raw request is parsed into a structured format.
// Parse the JSON body
Json::Value request;
Json::Reader reader;
if (!reader.parse(requestBody, request)) {
return rpcError(RpcInvalidParams, "Unable to parse JSON");
}
The parser extracts key fields:
std::string method = request["method"].asString();
Json::Value params = request["params"];
unsigned int apiVersion = request.get("api_version", 1).asUInt();
Different transports use different formats, which are normalized:
HTTP/WebSocket:
method or command fieldparams array or direct parametersgRPC:
Before processing the request, the system determines the caller's role based on the connection:
Source Location: src/xrpld/rpc/detail/Role.cpp
Role
requestRole(
Role const& required,
Port const& port,
Json::Value const& params,
beast::IP::Endpoint const& remoteIp,
std::string_view user)
{
if (isAdmin(port, params, remoteIp.address()))
return Role::ADMIN;
if (required == Role::ADMIN)
return Role::FORBID;
if (ipAllowed(
remoteIp.address(),
port.secure_gateway_nets_v4,
port.secure_gateway_nets_v6))
{
if (user.size())
return Role::IDENTIFIED;
return Role::PROXY;
}
return Role::GUEST;
}
FORBID < GUEST < USER < IDENTIFIED < ADMIN
Role descriptions:
[rpc_admin]
admin = 127.0.0.1, ::1
[secure_gateway]
ip = 192.168.1.100
The dispatcher searches the handler table for the requested command:
// Look up the handler
auto const it = handlerTable.find(method);
if (it == handlerTable.end()) {
return rpcError(RpcUnknownCommand, "Unknown method");
}
HandlerInfo const& handlerInfo = it->second;
If API versioning is in use:
if (apiVersion < handlerInfo.version_min ||
apiVersion > handlerInfo.version_max)
{
return rpcError(RpcInvalidParams);
}
The system checks if the caller has sufficient permissions:
if (context.role < handlerInfo.role) {
return rpcError(RpcNoPermission,
"You don't have permission for this command");
}
Example: A GUEST client attempting to call submit (requires USER role) would be rejected here.
Handlers may require specific runtime conditions:
if (handlerInfo.condition & Condition::NeedsCurrentLedger) {
if (!context.ledgerMaster.haveLedger()) {
return rpcError(RpcNoCurrent,
"Current ledger is not available");
}
}
if (handlerInfo.condition & Condition::NeedsNetworkConnection) {
if (context.netOps.getOperatingMode() < NetworkOPs::omSYNCING) {
return rpcError(RpcNoNetwork,
"Not connected to network");
}
}
if (handlerInfo.condition & Condition::NeedsClosedLedger) {
if (!context.ledgerMaster.getValidatedLedger()) {
return rpcError(RpcNoClosed,
"No validated ledger available");
}
}
A JsonContext object is built with all necessary information:
RPC::JsonContext context {
.params = params,
.app = app,
.consumer = consumer,
.role = role,
.ledger = ledger,
.netOps = app.getOPs(),
.ledgerMaster = app.getLedgerMaster(),
.apiVersion = apiVersion
};
Context provides:
params)app)consumer)role)ledger, ledgerMaster)netOps)The system tracks API usage to prevent abuse:
// Charge the client for this request
context.consumer.charge(Resource::feeReferenceRPC);
// Check if client has exceeded limits
if (context.consumer.isUnlimited() == false &&
context.consumer.balance() <= 0)
{
return rpcError(RpcSlowDown,
"You are making requests too frequently");
}
Resource limits are configured per client and prevent DoS attacks.
The handler function is called with the constructed context:
Json::Value result;
try {
result = handlerInfo.handler(context);
} catch (std::exception const& ex) {
return rpcError(RpcInternal, ex.what());
}
Error handling: Any uncaught exceptions are converted to RpcInternal errors.
For successful requests:
{
"result": {
"status": "success",
"account_data": {
"Account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"Balance": "1000000000",
...
},
"ledger_index": 12345
}
}
For failed requests:
{
"result": {
"error": "actNotFound",
"error_code": 19,
"error_message": "Account not found.",
"status": "error",
"request": {
"command": "account_info",
"account": "rInvalidAccount"
}
}
}
The JSON response is serialized back to the client's format:
HTTP/1.1 200 OK
Content-Type: application/json
{
"result": { ... }
}
{
"id": 1,
"status": "success",
"type": "response",
"result": { ... }
}
GetAccountInfoResponse {
account_data: { ... }
}
The response is sent back to the client over the same transport:
In brief: where the milliseconds actually go.
Each stage has associated latency:
| Stage | Typical Time | Notes |
|---|---|---|
| Reception | < 1 ms | Network overhead |
| Parsing | < 1 ms | JSON parsing |
| Lookup | < 0.1 ms | Hash table lookup |
| Permission Check | < 0.1 ms | Simple comparison |
| Condition Check | < 1 ms | Ledger availability |
| Handler Execution | 1-100 ms | Varies by handler |
| Serialization | < 1 ms | JSON encoding |
| Delivery | < 1 ms | Network overhead |
Total typical latency: 5-105 ms
In brief: which stage produces which class of error.
Different errors can occur at each stage:
RpcInvalidParams // Malformed JSON
RpcBadSyntax // Invalid structure
RpcUnknownCommand // Command not found
RpcInvalidParams // Version mismatch
RpcNoPermission // Insufficient role
RpcForbidden // Blacklisted client
RpcNoCurrent // No current ledger
RpcNoNetwork // Not connected
RpcNoClosed // No validated ledger
RpcActNotFound // Account not found
RpcLgrNotFound // Ledger not found
RpcInternal // Unexpected error
In brief: one concrete request walked through all twelve stages.
Let's trace a complete request:
{
"method": "account_info",
"params": [{
"account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"ledger_index": "validated"
}]
}
POST / HTTP/1.1
Host: localhost:5005
method = "account_info"
params = { "account": "rN7n...", "ledger_index": "validated" }
remoteIP = 127.0.0.1 → Role::ADMIN
handler = doAccountInfo
required_role = Role::USER
condition = NeedsCurrentLedger
ADMIN >= USER → PASS
haveLedger() == true → PASS
context.params = params
context.role = ADMIN
context.ledger = currentLedger
result = doAccountInfo(context)
{
"result": {
"status": "success",
"account_data": {
"Account": "rN7n7otQDd6FczFgLdlqtyMVrn3NnrcVXs",
"Balance": "1000000000"
}
}
}
The RPC request-response flow demonstrates Rippled's carefully orchestrated pipeline for handling API calls. From initial reception across multiple transport protocols, through parsing, role determination, permission checks, and condition validation, to handler invocation and response formatting, each stage serves a specific purpose. This multi-stage design enables early rejection of invalid requests, consistent error handling, proper resource management, and transport-agnostic processing. Mastering this flow is crucial for debugging RPC issues and understanding how custom handlers integrate into the system.
This module explained how the outside world talks to a node. rippled routes JSON-RPC, WebSocket, and gRPC requests through a central handler table; each handler receives a JsonContext, and every request runs the same gauntlet: command lookup, permission (role) check, condition validation, then handler invocation. It is the groundwork for building your own command.
To remember:
Json::Value doX(RPC::JsonContext&)JsonContext carries params, the caller's role, app services, and ledger accesssrc/xrpld/rpc and src/xrpld/rpc/handlersresult while WebSocket returns it at top level; parse the envelope per transportNext up. You can follow a request; now decide who is allowed to make it. Next: RPC authentication and error handling, roles and the error contract.
Resources
Assignments
0 of 2 complete