Compare commits

...

3 Commits

Author SHA1 Message Date
Bart
44a5da0632 fix: Give handler-specific RPC errors a code and message
Thirteen sites across five handlers assign `jss::error` a bare token,
skipping the `error_code`/`error_message` pair `rpc::injectError` sets.
Both consequences are visible on the wire: with no `error_code` the HTTP
status defaults to 200, so a load balancer sees success for a failed
request, and the version 2 envelope copies the pair unconditionally, so a
missing source produces an explicit `"code":null`/`"message":null`. Give
those tokens rows of their own, codes 100 to 109, and route every site
through `injectError`, preserving the `error_exception` detail `submit`
and `simulate` attach. `transaction_entry` keeps its four errors in the
handler's output as an rpc-spec `Status`, and `writeResult` reports each
through the status bridge, so the code and message land beside the ledger
fields the reply carries.

The `ledger_entry` helpers still report `invalidParams` (31) rather than
each token's own code. A version 1 or 2 client has read 31 for those
tokens for years, so changing it would break a client matching on the old
value; a comment on the helpers says so. `checkErrorValue` checks
`error_code` beside the token and the message, pinning each token's code
and failing on a token it does not know.

The `submit` and `simulate` arms reporting an internal error take no
coverage exclusion. Those arms are live, reached once
`NetworkOPs::processTransaction` or `Transaction::getJson` throws, and no
injection seam exists today, so the gap belongs in the test list rather
than behind a marker that hides it. Two more exclusions in `Simulate.cpp`
go, on arms that are live as well: the `Account` type check, which a
numeric `Account` reaches and a new `simulate` case sends, and the
fallback `engine_result` arm, which gets a comment saying why it stays.
2026-10-10 20:02:25 +09:00
Bart
db3764b231 fix: Give every error code an HTTP status
Four rows of the error table name no HTTP status, so the `ErrorInfo`
constructor defaults them to 200. The `ripplerpc: "3.0"` envelope derives
the status from the code, so a reply reporting an error claims success
and anything reading the status, a load balancer above all, reads success
too. Give all four one: `actNotFound` answers 404, matching every
`*NotFound` sibling but `entryNotFound`, and `actMalformed`,
`alreadyMultisig` and `alreadySingleSig` answer 400. Then drop the
constructor that defaulted a status, so no row can omit one again. A gtest
lists by hand the codes that have no row, so an enumerator added without
one fails it, and asserts that every other code names a status other than
200.

No client reads a new status here. `legacyHttpStatus` reports 200 for
exactly those four rows, so the 3.0 envelope answers what it always has.
That list is closed, naming the rows that had no status of their own, so
a row added later reports whatever the table says. The test suite names
the two statuses it compares against most, 200 and 400, as `kOk` and
`kBadRequest`, and every existing assertion on them uses the name.
2026-10-10 20:02:25 +09:00
Bart
5795eb3be2 style: Realign the error table
The columns of the error table had drifted apart as rows were added, so a reader scanning it follows a ragged edge and a new row has no alignment to copy. Realign all four columns on one set of widths inside the existing `clang-format off` guard, changing no row's content.
2026-10-10 16:41:14 +09:00
17 changed files with 752 additions and 147 deletions

View File

@@ -29,6 +29,8 @@ This version is supported by all `xrpld` versions. For WebSocket and HTTP JSON-R
- A request echoed back in an error reply now has every credential-bearing field masked: `admin_password`, `admin_user`, `passphrase`, `password`, `secret`, `seed`, `seed_hex`, `url_password`, `url_username` and `username`. Nesting no longer matters, so a credential inside `params` is masked too. The same masking is applied to every request and reply written to the log, and it covers six further names that only a reply carries: `master_key`, `master_seed`, `master_seed_hex`, `validation_key`, `validation_private_key` and `validation_seed`, which is how `wallet_propose` and `validation_create` used to write a live private key to the log. A request or reply written to the log is truncated at 10,000 characters.
- The command line client no longer prints a credential the operator did not type. A failing command echoes the request it built under `request_sent`, which carries the `admin_password` the client copies out of `[port_rpc]` in the config, so `./xrpld account_info rBogus` printed that password to stdout and into any captured output. `request_sent` is now masked. The `rpc` member beside it, which echoes the arguments as they were typed, is unchanged. The command line client also no longer writes an unparsed `json` or `ripple_path_find` argument to its trace log before parsing it, where a `secret` inside that argument could not be masked; it logs the parsed request instead, masked. The reply it receives is logged the same way, parsed and masked, where the raw body was written before, a `validation_create` answer included.
- A WebSocket frame that does not parse, or exceeds the request size limit, is answered `{"type": "error", "error": "jsonInvalid", "size": <bytes>}`. The frame's body is reported by size rather than echoed back in a `value` member, since a body that does not parse has no fields to mask. A client that read `value` gets `size` instead.
- Four error codes that named no HTTP status of their own, and so answered 200 on a reply reporting an error, now name one: `actMalformed`, `alreadyMultisig` and `alreadySingleSig` answer 400, and `actNotFound` answers 404. **No shipped envelope reports these four.** A request sending `ripplerpc: "3.0"` still receives 200 for all four, as it always has, so `account_info` on a malformed account or one the ledger does not hold answers 200 exactly as before.
- `submit`, `simulate`, `transaction_entry`, `ledger_entry` and `ledger_accept`: Errors from these methods now include `error_code` and `error_message` alongside the `error` token, as every other method already did. Each error now answers the status its code names: 400 for a malformed request, 404 for `transactionNotFound`, 500 for an internal failure, and 501 for `notYetImplemented` and `notStandAlone`. That status change reaches only a request sending `ripplerpc: "3.0"`, which is the envelope that derives the status from the error. With `ripplerpc` `"1.0"` the status stays 200 and the two new members appear beside `error`; with `"2.0"` the status stays 200, `error_code` appears, and the `code` and `message` members carry the code and the message rather than null, since that envelope copies them from `error_code` and `error_message` and drops `error_message`.
## XRP Ledger server version 3.5.0

View File

@@ -144,7 +144,27 @@ enum ErrorCodeI {
RpcEntryNotFound = 98,
RpcUnexpectedLedgerType = 99,
RpcLast = RpcUnexpectedLedgerType // rpcLAST should always equal the last code.
// submit + simulate
RpcInvalidTransaction = 100,
RpcInternalSubmit = 101,
RpcInternalJson = 102,
RpcInternalSimulate = 103,
// transaction_entry
RpcFieldNotFoundTransaction = 104,
RpcNotYetImplemented = 105,
RpcTransactionNotFound = 106,
// transaction_entry + ledger_entry
RpcMalformedRequest = 107,
// ledger_accept
RpcNotStandAlone = 108,
// ledger_entry, API version 1 only
RpcUnknownOption = 109,
RpcLast = RpcUnknownOption // rpcLAST should always equal the last code.
};
/**
@@ -180,11 +200,6 @@ struct ErrorInfo
{
}
constexpr ErrorInfo(ErrorCodeI code, char const* token, char const* message)
: code(code), token(token), message(message), httpStatus(200)
{
}
constexpr ErrorInfo(ErrorCodeI code, char const* token, char const* message, int httpStatus)
: code(code), token(token), message(message), httpStatus(httpStatus)
{

View File

@@ -245,7 +245,7 @@ JSS(ephemeral_key); // out: ValidatorInfo
JSS(error); // out: error
JSS(errored); //
JSS(error_code); // out: error
JSS(error_exception); // out: Submit
JSS(error_exception); // out: Submit, Simulate
JSS(error_message); // out: error
JSS(expand); // in: handler/Ledger
JSS(expected_date); // out: any (warnings)

View File

@@ -20,88 +20,102 @@ namespace detail {
// will remain in the object file. But the string literals will remain.
//
// There's a certain amount of tension in determining the correct HTTP
// status to associate with a given RPC error. Initially all RPC errors
// returned 200 (OK). And that's the default behavior if no HTTP status code
// is specified below.
// status to associate with a given RPC error. Every row below names its
// HTTP status, and a row that omits it does not compile, since ErrorInfo has
// no constructor that leaves the status out.
//
// The codes currently selected target the load balancer fail-over use case.
// If a query fails on one node but is likely to have a positive outcome
// on a different node, then the failure should return a 4xx/5xx range
// status code.
// The rows below are aligned by hand into four columns, so that a wrong status or a mistyped token
// is visible by scanning one column. That cannot be had inside the 100-column limit once the
// longest enumerator and the longest message sit in one row. So this region is exempt from the
// limit by design rather than by oversight.
// clang-format off
static constexpr ErrorInfo kUnorderedErrorInfos[]{
{RpcActMalformed, "actMalformed", "Account malformed."},
{RpcActNotFound, "actNotFound", "Account not found."},
{RpcAlreadyMultisig, "alreadyMultisig", "Already multisigned."},
{RpcAlreadySingleSig, "alreadySingleSig", "Already single-signed."},
{RpcAmendmentBlocked, "amendmentBlocked", "Amendment blocked, need upgrade.", 503},
{RpcExpiredValidatorList, "unlBlocked", "Validator list expired.", 503},
{RpcAtxDeprecated, "deprecated", "Use the new API or specify a ledger range.", 400},
{RpcBadKeyType, "badKeyType", "Bad key type.", 400},
{RpcBadFeature, "badFeature", "Feature unknown or invalid.", 500},
{RpcBadIssuer, "badIssuer", "Issuer account malformed.", 400},
{RpcBadMarket, "badMarket", "No such market.", 404},
{RpcBadSecret, "badSecret", "Secret does not match account.", 403},
{RpcBadSeed, "badSeed", "Disallowed seed.", 403},
{RpcBadSyntax, "badSyntax", "Syntax error.", 400},
{RpcChannelMalformed, "channelMalformed", "Payment channel is malformed.", 400},
{RpcChannelAmtMalformed, "channelAmtMalformed", "Payment channel amount is malformed.", 400},
{RpcCommandMissing, "commandMissing", "Missing command entry.", 400},
{RpcDbDeserialization, "dbDeserialization", "Database deserialization error.", 502},
{RpcDstActMalformed, "dstActMalformed", "Destination account is malformed.", 400},
{RpcDstActMissing, "dstActMissing", "Destination account not provided.", 400},
{RpcDstActNotFound, "dstActNotFound", "Destination account not found.", 404},
{RpcDstAmtMalformed, "dstAmtMalformed", "Destination amount/currency/issuer is malformed.", 400},
{RpcDstAmtMissing, "dstAmtMissing", "Destination amount/currency/issuer is missing.", 400},
{RpcDstIsrMalformed, "dstIsrMalformed", "Destination issuer is malformed.", 400},
{RpcExcessiveLgrRange, "excessiveLgrRange", "Ledger range exceeds 1000.", 400},
{RpcForbidden, "forbidden", "Bad credentials.", 403},
{RpcHighFee, "highFee", "Current transaction fee exceeds your limit.", 402},
{RpcInternal, "internal", "Internal error.", 500},
{RpcInvalidLgrRange, "invalidLgrRange", "Ledger range is invalid.", 400},
{RpcInvalidParams, "invalidParams", "Invalid parameters.", 400},
{RpcInvalidHotwallet, "invalidHotWallet", "Invalid hotwallet.", 400},
{RpcIssueMalformed, "issueMalformed", "Issue is malformed.", 400},
{RpcJsonRpc, "json_rpc", "JSON-RPC transport error.", 500},
{RpcLgrIdxsInvalid, "lgrIdxsInvalid", "Ledger indexes invalid.", 400},
{RpcLgrIdxMalformed, "lgrIdxMalformed", "Ledger index malformed.", 400},
{RpcLgrNotFound, "lgrNotFound", "Ledger not found.", 404},
{RpcLgrNotValidated, "lgrNotValidated", "Ledger not validated.", 202},
{RpcMasterDisabled, "masterDisabled", "Master key is disabled.", 403},
{RpcNotEnabled, "notEnabled", "Not enabled in configuration.", 501},
{RpcNotImpl, "notImpl", "Not implemented.", 501},
{RpcNotReady, "notReady", "Not ready to handle this request.", 503},
{RpcNotSupported, "notSupported", "Operation not supported.", 501},
{RpcNoClosed, "noClosed", "Closed ledger is unavailable.", 503},
{RpcNoCurrent, "noCurrent", "Current ledger is unavailable.", 503},
{RpcNotSynced, "notSynced", "Not synced to the network.", 503},
{RpcNoEvents, "noEvents", "Current transport does not support events.", 405},
{RpcNoNetwork, "noNetwork", "Not synced to the network.", 503},
{RpcWrongNetwork, "wrongNetwork", "Wrong network.", 503},
{RpcNoPermission, "noPermission", "You don't have permission for this command.", 401},
{RpcNoPfRequest, "noPathRequest", "No pathfinding request in progress.", 404},
{RpcObjectNotFound, "objectNotFound", "The requested object was not found.", 404},
{RpcPublicMalformed, "publicMalformed", "Public key is malformed.", 400},
{RpcSendmaxMalformed, "sendMaxMalformed", "SendMax amount malformed.", 400},
{RpcSigningMalformed, "signingMalformed", "Signing of transaction is malformed.", 400},
{RpcSlowDown, "slowDown", "You are placing too much load on the server.", 429},
{RpcSrcActMalformed, "srcActMalformed", "Source account is malformed.", 400},
{RpcSrcActMissing, "srcActMissing", "Source account not provided.", 400},
{RpcSrcActNotFound, "srcActNotFound", "Source account not found.", 404},
{RpcDelegateActNotFound, "delegateActNotFound", "Delegate account not found.", 404},
{RpcSrcCurMalformed, "srcCurMalformed", "Source currency is malformed.", 400},
{RpcSrcIsrMalformed, "srcIsrMalformed", "Source issuer is malformed.", 400},
{RpcStreamMalformed, "malformedStream", "Stream malformed.", 400},
{RpcTooBusy, "tooBusy", "The server is too busy to help you now.", 503},
{RpcTxnNotFound, "txnNotFound", "Transaction not found.", 404},
{RpcUnknownCommand, "unknownCmd", "Unknown method.", 405},
{RpcOracleMalformed, "oracleMalformed", "Oracle request is malformed.", 400},
{RpcBadCredentials, "badCredentials", "Credentials do not exist, are not accepted, or have expired.", 400},
{RpcTxSigned, "transactionSigned", "Transaction should not be signed.", 400},
{RpcDomainMalformed, "domainMalformed", "Domain is malformed.", 400},
{RpcEntryNotFound, "entryNotFound", "Entry not found.", 400},
{RpcUnexpectedLedgerType, "unexpectedLedgerType", "Unexpected ledger type.", 400},
{RpcActMalformed, "actMalformed", "Account malformed.", 400},
{RpcActNotFound, "actNotFound", "Account not found.", 404},
{RpcAlreadyMultisig, "alreadyMultisig", "Already multisigned.", 400},
{RpcAlreadySingleSig, "alreadySingleSig", "Already single-signed.", 400},
{RpcAmendmentBlocked, "amendmentBlocked", "Amendment blocked, need upgrade.", 503},
{RpcExpiredValidatorList, "unlBlocked", "Validator list expired.", 503},
{RpcAtxDeprecated, "deprecated", "Use the new API or specify a ledger range.", 400},
{RpcBadKeyType, "badKeyType", "Bad key type.", 400},
{RpcBadFeature, "badFeature", "Feature unknown or invalid.", 500},
{RpcBadIssuer, "badIssuer", "Issuer account malformed.", 400},
{RpcBadMarket, "badMarket", "No such market.", 404},
{RpcBadSecret, "badSecret", "Secret does not match account.", 403},
{RpcBadSeed, "badSeed", "Disallowed seed.", 403},
{RpcBadSyntax, "badSyntax", "Syntax error.", 400},
{RpcChannelMalformed, "channelMalformed", "Payment channel is malformed.", 400},
{RpcChannelAmtMalformed, "channelAmtMalformed", "Payment channel amount is malformed.", 400},
{RpcCommandMissing, "commandMissing", "Missing command entry.", 400},
{RpcDbDeserialization, "dbDeserialization", "Database deserialization error.", 502},
{RpcDstActMalformed, "dstActMalformed", "Destination account is malformed.", 400},
{RpcDstActMissing, "dstActMissing", "Destination account not provided.", 400},
{RpcDstActNotFound, "dstActNotFound", "Destination account not found.", 404},
{RpcDstAmtMalformed, "dstAmtMalformed", "Destination amount/currency/issuer is malformed.", 400},
{RpcDstAmtMissing, "dstAmtMissing", "Destination amount/currency/issuer is missing.", 400},
{RpcDstIsrMalformed, "dstIsrMalformed", "Destination issuer is malformed.", 400},
{RpcExcessiveLgrRange, "excessiveLgrRange", "Ledger range exceeds 1000.", 400},
{RpcForbidden, "forbidden", "Bad credentials.", 403},
{RpcHighFee, "highFee", "Current transaction fee exceeds your limit.", 402},
{RpcInternal, "internal", "Internal error.", 500},
{RpcInvalidLgrRange, "invalidLgrRange", "Ledger range is invalid.", 400},
{RpcInvalidParams, "invalidParams", "Invalid parameters.", 400},
{RpcInvalidHotwallet, "invalidHotWallet", "Invalid hotwallet.", 400},
{RpcIssueMalformed, "issueMalformed", "Issue is malformed.", 400},
{RpcJsonRpc, "json_rpc", "JSON-RPC transport error.", 500},
{RpcLgrIdxsInvalid, "lgrIdxsInvalid", "Ledger indexes invalid.", 400},
{RpcLgrIdxMalformed, "lgrIdxMalformed", "Ledger index malformed.", 400},
{RpcLgrNotFound, "lgrNotFound", "Ledger not found.", 404},
{RpcLgrNotValidated, "lgrNotValidated", "Ledger not validated.", 202},
{RpcMasterDisabled, "masterDisabled", "Master key is disabled.", 403},
{RpcNotEnabled, "notEnabled", "Not enabled in configuration.", 501},
{RpcNotImpl, "notImpl", "Not implemented.", 501},
{RpcNotReady, "notReady", "Not ready to handle this request.", 503},
{RpcNotSupported, "notSupported", "Operation not supported.", 501},
{RpcNoClosed, "noClosed", "Closed ledger is unavailable.", 503},
{RpcNoCurrent, "noCurrent", "Current ledger is unavailable.", 503},
{RpcNotSynced, "notSynced", "Not synced to the network.", 503},
{RpcNoEvents, "noEvents", "Current transport does not support events.", 405},
{RpcNoNetwork, "noNetwork", "Not synced to the network.", 503},
{RpcWrongNetwork, "wrongNetwork", "Wrong network.", 503},
{RpcNoPermission, "noPermission", "You don't have permission for this command.", 401},
{RpcNoPfRequest, "noPathRequest", "No pathfinding request in progress.", 404},
{RpcObjectNotFound, "objectNotFound", "The requested object was not found.", 404},
{RpcPublicMalformed, "publicMalformed", "Public key is malformed.", 400},
{RpcSendmaxMalformed, "sendMaxMalformed", "SendMax amount malformed.", 400},
{RpcSigningMalformed, "signingMalformed", "Signing of transaction is malformed.", 400},
{RpcSlowDown, "slowDown", "You are placing too much load on the server.", 429},
{RpcSrcActMalformed, "srcActMalformed", "Source account is malformed.", 400},
{RpcSrcActMissing, "srcActMissing", "Source account not provided.", 400},
{RpcSrcActNotFound, "srcActNotFound", "Source account not found.", 404},
{RpcDelegateActNotFound, "delegateActNotFound", "Delegate account not found.", 404},
{RpcSrcCurMalformed, "srcCurMalformed", "Source currency is malformed.", 400},
{RpcSrcIsrMalformed, "srcIsrMalformed", "Source issuer is malformed.", 400},
{RpcStreamMalformed, "malformedStream", "Stream malformed.", 400},
{RpcTooBusy, "tooBusy", "The server is too busy to help you now.", 503},
{RpcTxnNotFound, "txnNotFound", "Transaction not found.", 404},
{RpcUnknownCommand, "unknownCmd", "Unknown method.", 405},
{RpcOracleMalformed, "oracleMalformed", "Oracle request is malformed.", 400},
{RpcBadCredentials, "badCredentials", "Credentials do not exist, are not accepted, or have expired.", 400},
{RpcTxSigned, "transactionSigned", "Transaction should not be signed.", 400},
{RpcDomainMalformed, "domainMalformed", "Domain is malformed.", 400},
{RpcEntryNotFound, "entryNotFound", "Entry not found.", 400},
{RpcUnexpectedLedgerType, "unexpectedLedgerType", "Unexpected ledger type.", 400},
{RpcInvalidTransaction, "invalidTransaction", "Transaction is invalid.", 400},
{RpcInternalSubmit, "internalSubmit", "Internal error during submit.", 500},
{RpcInternalJson, "internalJson", "Internal error during JSON handling.", 500},
{RpcInternalSimulate, "internalSimulate", "Internal error during simulate.", 500},
{RpcFieldNotFoundTransaction, "fieldNotFoundTransaction", "Missing required field.", 400},
{RpcNotYetImplemented, "notYetImplemented", "Not yet implemented.", 501},
{RpcTransactionNotFound, "transactionNotFound", "Transaction not found.", 404},
{RpcMalformedRequest, "malformedRequest", "Request is malformed.", 400},
{RpcNotStandAlone, "notStandAlone", "Server is not running stand-alone.", 501},
{RpcUnknownOption, "unknownOption", "Unknown option.", 400},
};
// clang-format on

View File

@@ -56,8 +56,10 @@
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <map>
#include <memory>
#include <optional>
#include <set>
#include <source_location>
#include <stdexcept>
#include <string>
@@ -164,6 +166,68 @@ class LedgerEntry_test : public beast::unit_test::Suite
jv[jss::error] == err,
"Expected error " + err + ", received " + jv[jss::error].asString() + ", at line " +
std::to_string(location.line()) + ", " + jv.toStyledString());
// Wire values pinned as literals: read from the error table, the expectation would
// agree with any change to the row that produced the reply.
// These are raised through injectError and carry their own code.
static std::map<std::string, int> const kCodes{
{"entryNotFound", 98},
{"invalidParams", 31},
{"lgrNotFound", 21},
{"unexpectedLedgerType", 99},
{"unknownOption", 109},
};
// The tokens the ledger_entry helpers name, all reporting the generic invalidParams;
// see LedgerEntryHelpers.h. Listed, so a misspelled token reaches the else below.
static std::set<std::string> const kMalformedTokens{
"malformedAccount",
"malformedAddress",
"malformedAuthorized",
"malformedAuthorizedCredentials",
"malformedBridgeAccount",
"malformedBroker",
"malformedCurrency",
"malformedDirRoot",
"malformedDocumentID",
"malformedIssue",
"malformedIssuingChainDoor",
"malformedLockingChainDoor",
"malformedMPTIssuanceID",
"malformedMPTokenIssuance",
"malformedOwner",
"malformedRequest",
"malformedSeq",
"malformedSponsee",
"malformedSponsor",
"malformedXChainOwnedClaimID",
"malformedXChainOwnedCreateAccountClaimID",
};
auto const expectCode = [&](int expected) {
BEAST_EXPECTS(
jv[jss::error_code] == expected,
"Expected error_code " + std::to_string(expected) + " for " + err +
", received " + jv[jss::error_code].toStyledString() + ", at line " +
std::to_string(location.line()));
};
if (auto const it = kCodes.find(err); it != kCodes.end())
{
expectCode(it->second);
}
else if (kMalformedTokens.contains(err))
{
expectCode(RpcInvalidParams);
}
else
{
// A token in neither list is a typo; reporting 31 like the rest would let it pass.
BEAST_EXPECTS(
false,
"Token " + err + " names no error code, at line " +
std::to_string(location.line()));
}
}
if (msg.empty())
{
@@ -340,7 +404,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
apiVersion, "json", "ledger_entry", to_string(correctRequest))[jss::result];
if (apiVersion < 2u)
{
checkErrorValue(jrr, "unknownOption", "", location);
checkErrorValue(jrr, "unknownOption", "Unknown option.", location);
}
else
{
@@ -546,7 +610,7 @@ class LedgerEntry_test : public beast::unit_test::Suite
if (apiVersion < 2u)
{
checkErrorValue(jrr, "unknownOption", "");
checkErrorValue(jrr, "unknownOption", "Unknown option.");
}
else
{

View File

@@ -328,6 +328,21 @@ class Simulate_test : public beast::unit_test::Suite
resp[jss::result][jss::error].toStyledString());
BEAST_EXPECT(resp[jss::result][jss::error_message] == "Invalid field 'tx.Account'.");
}
{
// A non-string `Account`, which no earlier check rejects: the `tx_json` arm validates
// only that it is an object, and the check above only that `Account` is present.
json::Value params;
json::Value txJson = json::ValueType::Object;
txJson[jss::TransactionType] = jss::AccountSet;
txJson[jss::Account] = 123;
params[jss::tx_json] = txJson;
auto const resp = env.rpc("json", "simulate", to_string(params));
BEAST_EXPECTS(
resp[jss::result][jss::error] == "invalidParams",
resp[jss::result][jss::error].toStyledString());
BEAST_EXPECT(resp[jss::result][jss::error_message] == "Invalid field 'tx.Account'.");
}
{
// Account doesn't exist for Sequence autofill
json::Value params;

View File

@@ -18,6 +18,7 @@
#include <xrpl/protocol/jss.h>
#include <memory>
#include <source_location>
#include <stdexcept>
#include <string>
@@ -25,6 +26,45 @@ namespace xrpl {
class TransactionEntry_test : public beast::unit_test::Suite
{
/**
* Asserts the reply carries the whole error, not just the token.
*
* @param result The reply's payload.
* @param token The `error` token expected.
* @param code The `error_code` expected.
* @param message The `error_message` expected.
* @param location The caller, reported when an assertion fails.
*/
void
checkCodedError(
json::Value const& result,
std::string const& token,
ErrorCodeI code,
std::string const& message,
std::source_location const location = std::source_location::current())
{
auto const at = std::to_string(location.line());
BEAST_EXPECTS(result[jss::error] == token, at);
BEAST_EXPECTS(result[jss::error_code] == code, at);
BEAST_EXPECTS(result[jss::error_message] == message, at);
BEAST_EXPECTS(result[jss::status] == "error", at);
}
/**
* Wire values pinned as literals, so reassigning a code fails here.
*/
void
testErrorCodeValues()
{
testcase("Error code values are stable");
BEAST_EXPECT(static_cast<int>(RpcFieldNotFoundTransaction) == 104);
BEAST_EXPECT(static_cast<int>(RpcNotYetImplemented) == 105);
BEAST_EXPECT(static_cast<int>(RpcTransactionNotFound) == 106);
BEAST_EXPECT(static_cast<int>(RpcMalformedRequest) == 107);
}
void
testBadInput()
{
@@ -38,8 +78,12 @@ class TransactionEntry_test : public beast::unit_test::Suite
{
// no params
auto const result = env.client().invoke("transaction_entry", {})[jss::result];
BEAST_EXPECT(result[jss::error] == "fieldNotFoundTransaction");
BEAST_EXPECT(result[jss::status] == "error");
// The message names the missing field rather than the table's default.
checkCodedError(
result,
"fieldNotFoundTransaction",
RpcFieldNotFoundTransaction,
"Missing field 'tx_hash'.");
}
{
@@ -55,8 +99,8 @@ class TransactionEntry_test : public beast::unit_test::Suite
params[jss::ledger] = "current";
params[jss::tx_hash] = "DEADBEEF";
auto const result = env.client().invoke("transaction_entry", params)[jss::result];
BEAST_EXPECT(result[jss::error] == "notYetImplemented");
BEAST_EXPECT(result[jss::status] == "error");
checkCodedError(
result, "notYetImplemented", RpcNotYetImplemented, "Not yet implemented.");
}
{
@@ -65,8 +109,8 @@ class TransactionEntry_test : public beast::unit_test::Suite
params[jss::tx_hash] =
"E2FE8D4AF3FCC3944DDF6CD8CDDC5E3F0AD50863EF8919AFEF10CB6408CD4D05";
auto const result = env.client().invoke("transaction_entry", params)[jss::result];
BEAST_EXPECT(result[jss::error] == "notYetImplemented");
BEAST_EXPECT(result[jss::status] == "error");
checkCodedError(
result, "notYetImplemented", RpcNotYetImplemented, "Not yet implemented.");
BEAST_EXPECT(result.isMember(jss::ledger_current_index));
BEAST_EXPECT(!result.isMember(jss::ledger_hash));
BEAST_EXPECT(result[jss::validated] == false);
@@ -78,18 +122,19 @@ class TransactionEntry_test : public beast::unit_test::Suite
params[jss::tx_hash] = "DEADBEEF";
auto const result = env.client().invoke("transaction_entry", params)[jss::result];
BEAST_EXPECT(!result[jss::ledger_hash].asString().empty());
BEAST_EXPECT(result[jss::error] == "malformedRequest");
BEAST_EXPECT(result[jss::status] == "error");
checkCodedError(
result, "malformedRequest", RpcMalformedRequest, "Request is malformed.");
}
// A `tx_hash` that is not a string is malformed, the same as one that is not hex.
for (auto const type : {json::ValueType::Object, json::ValueType::Array})
{
json::Value params{json::ValueType::Object};
params[jss::ledger] = "closed";
params[jss::tx_hash] = json::Value{type};
auto const result = env.client().invoke("transaction_entry", params)[jss::result];
BEAST_EXPECT(result[jss::error] == "malformedRequest");
BEAST_EXPECT(result[jss::status] == "error");
checkCodedError(
result, "malformedRequest", RpcMalformedRequest, "Request is malformed.");
}
std::string const txHash{
@@ -150,8 +195,11 @@ class TransactionEntry_test : public beast::unit_test::Suite
// Valid structure, but transaction not found.
json::Value const result{env.rpc("transaction_entry", txHash, "closed")};
BEAST_EXPECT(!result[jss::result][jss::ledger_hash].asString().empty());
BEAST_EXPECT(result[jss::result][jss::error] == "transactionNotFound");
BEAST_EXPECT(result[jss::result][jss::status] == "error");
checkCodedError(
result[jss::result],
"transactionNotFound",
RpcTransactionNotFound,
"Transaction not found.");
}
}
@@ -374,6 +422,7 @@ public:
void
run() override
{
testErrorCodeValues();
testBadInput();
forAllApiVersions([this](unsigned apiVersion) { testRequest(apiVersion); });
}

View File

@@ -1,7 +1,9 @@
#include <test/jtx/Account.h>
#include <test/jtx/CaptureLogs.h>
#include <test/jtx/Env.h>
#include <test/jtx/JSONRPCClient.h>
#include <test/jtx/WSClient.h>
#include <test/jtx/amount.h>
#include <test/jtx/envconfig.h>
#include <xrpld/app/ledger/LedgerMaster.h>
@@ -17,6 +19,7 @@
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/ApiVersion.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/server/LoadFeeTrack.h>
#include <xrpl/server/NetworkOPs.h>
@@ -211,6 +214,9 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
using Response = boost::beast::http::response<boost::beast::http::string_body>;
static constexpr auto kOk = boost::beast::http::status::ok;
static constexpr auto kBadRequest = boost::beast::http::status::bad_request;
/**
* Posts @p body to the RPC port and returns the reply, parsed.
*
@@ -503,7 +509,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
doHTTPRequest(env, yield, false, resp, ec);
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
}
// secure request
@@ -513,7 +519,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
doHTTPRequest(env, yield, true, resp, ec);
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
}
}
@@ -639,7 +645,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
// finally if we use the correct user/pass encoded, we should get a 200
auth.set("Authorization", "Basic " + base64Encode(user + ":" + pass));
doHTTPRequest(env, yield, secure, resp, ec, to_string(jr), auth);
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
BEAST_EXPECT(!resp.body().empty());
}
@@ -917,7 +923,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
BEAST_EXPECT(resp.body().contains("connectivity is working."));
// mark the Network as having an Amendment Warning, but won't fail
@@ -962,7 +968,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
BEAST_EXPECT(resp.body().contains("connectivity is working."));
// with ELB_SUPPORT, status still does not indicate a problem
@@ -982,7 +988,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
BEAST_EXPECT(resp.body().contains("connectivity is working."));
}
@@ -1045,7 +1051,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
BEAST_EXPECT(resp.body().contains("connectivity is working."));
// mark the Network as Amendment Blocked, but still won't fail until
@@ -1093,7 +1099,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
if (!BEAST_EXPECTS(!ec, ec.message()))
return;
BEAST_EXPECT(resp.result() == boost::beast::http::status::ok);
BEAST_EXPECT(resp.result() == kOk);
BEAST_EXPECT(resp.body().contains("connectivity is working."));
env.app().config().elbSupport = true;
@@ -1129,7 +1135,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
{
boost::beast::http::response<boost::beast::http::string_body> resp;
doHTTPRequest(env, yield, false, resp, ec, "{}");
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Unable to parse request: \r\n");
}
@@ -1138,7 +1144,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
json::Value jv;
jv["invalid"] = 1;
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Null method\r\n");
}
@@ -1147,7 +1153,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
json::Value jv(json::ValueType::Array);
jv.append("invalid");
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Unable to parse request: \r\n");
}
@@ -1158,7 +1164,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
j["invalid"] = 1;
jv.append(j);
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Unable to parse request: \r\n");
}
@@ -1168,7 +1174,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
jv[jss::method] = "batch";
jv[jss::params] = 2;
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Malformed batch request\r\n");
}
@@ -1179,7 +1185,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
jv[jss::params] = json::ValueType::Object;
jv[jss::params]["invalid"] = 3;
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Malformed batch request\r\n");
}
@@ -1188,7 +1194,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
boost::beast::http::response<boost::beast::http::string_body> resp;
jv[jss::method] = json::ValueType::Null;
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "Null method\r\n");
}
@@ -1196,7 +1202,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
boost::beast::http::response<boost::beast::http::string_body> resp;
jv[jss::method] = 1;
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "method is not string\r\n");
}
@@ -1204,7 +1210,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
boost::beast::http::response<boost::beast::http::string_body> resp;
jv[jss::method] = "";
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "method is empty\r\n");
}
@@ -1213,7 +1219,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
jv[jss::method] = "some_method";
jv[jss::params] = "params";
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "params unparsable\r\n");
}
@@ -1222,11 +1228,275 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En
jv[jss::params] = json::ValueType::Array;
jv[jss::params][0u] = "not an object";
doHTTPRequest(env, yield, false, resp, ec, to_string(jv));
BEAST_EXPECT(resp.result() == boost::beast::http::status::bad_request);
BEAST_EXPECT(resp.result() == kBadRequest);
BEAST_EXPECT(resp.body() == "params unparsable\r\n");
}
}
/**
* Builds a request carrying the `ripplerpc` parameter, which selects the
* reply envelope.
*
* @param method The method the request names.
* @param version The `ripplerpc` value. Empty omits the field entirely.
* @return The request, serialized.
*/
static std::string
makeRippleRpcRequest(std::string_view method, std::string_view version)
{
json::Value jv;
jv[jss::method] = method;
jv[jss::params] = json::ValueType::Array;
json::Value params(json::ValueType::Object);
if (!version.empty())
params[jss::ripplerpc] = version;
jv[jss::params][0u] = params;
return to_string(jv);
}
/**
* The five handlers that report a bare token carry a code and message with
* it.
*
* @param yield The coroutine the requests run on.
*/
void
testHandlerErrorsCarryCodes(boost::asio::yield_context& yield)
{
testcase("Handler errors carry a code and message");
using namespace test::jtx;
Env env{*this};
boost::system::error_code ec;
// Both `ripplerpc` envelopes need all three present: version 3 derives the HTTP status
// from `error_code`, and version 2 copies the pair into `code` and `message`, so a missing
// field leaves the reply claiming success or carrying an explicit null. Expected values are
// literals, so a row changing under a client turns this red.
struct Case
{
std::string_view method;
std::string_view token;
ErrorCodeI code;
std::string_view message;
unsigned status;
};
for (auto const& [method, token, code, message, httpStatus] : {
Case{
.method = "transaction_entry",
.token = "fieldNotFoundTransaction",
.code = RpcFieldNotFoundTransaction,
.message = "Missing field 'tx_hash'.",
.status = 400},
Case{
.method = "vault_info",
.token = "invalidParams",
.code = RpcInvalidParams,
.message = "Must specify either 'vault_id' or both 'owner' and 'seq'.",
.status = 400},
// Reached only below API version 2; see the api_version case.
Case{
.method = "ledger_entry",
.token = "unknownOption",
.code = RpcUnknownOption,
.message = "Unknown option.",
.status = 400},
})
{
auto const status = static_cast<boost::beast::http::status>(httpStatus);
auto const label = std::string{method};
// Version 2 must report a real code and message, never null.
{
Response resp;
auto const reply = postAndParse(
env,
yield,
resp,
ec,
makeRippleRpcRequest(method, rpc::kRippleRpcVersion2),
label);
auto const& error = reply[jss::error];
BEAST_EXPECTS(error[jss::error] == token, label);
BEAST_EXPECTS(error[jss::error_code] == code, label);
BEAST_EXPECTS(error[jss::code] == code, label);
BEAST_EXPECTS(error[jss::message] == message, label);
// Version 2 always answers 200, whatever the error code.
BEAST_EXPECTS(resp.result() == kOk, label);
}
// Version 3 maps that code onto the HTTP status.
{
Response resp;
doHTTPRequest(
env,
yield,
false,
resp,
ec,
makeRippleRpcRequest(method, rpc::kRippleRpcVersion3));
BEAST_EXPECTS(resp.result() == status, label);
}
// Version 1 keeps the token and gains a message.
{
Response resp;
auto const reply = postAndParse(
env,
yield,
resp,
ec,
makeRippleRpcRequest(method, rpc::kRippleRpcVersion1),
label);
auto const& result = reply[jss::result];
BEAST_EXPECTS(result[jss::error] == token, label);
BEAST_EXPECTS(result[jss::error_code] == code, label);
BEAST_EXPECTS(result[jss::error_message] == message, label);
}
}
// `ledger_entry` reports `unknownOption` below API version 2 and `invalidParams` from
// version 2 onwards. Both carry a code, so both select an HTTP status under ripplerpc 3.
for (auto const apiVersion : {1u, 2u})
{
Response resp;
json::Value jv;
jv[jss::method] = "ledger_entry";
jv[jss::params] = json::ValueType::Array;
jv[jss::params][0u] = json::ValueType::Object;
jv[jss::params][0u][jss::ripplerpc] = rpc::kRippleRpcVersion3;
jv[jss::params][0u][jss::api_version] = apiVersion;
// Literals again, for the reason the case table above gives.
auto const expected = apiVersion < 2 ? RpcUnknownOption : RpcInvalidParams;
auto const token = apiVersion < 2 ? "unknownOption" : "invalidParams";
auto const label = std::to_string(apiVersion);
auto const reply = postAndParse(env, yield, resp, ec, to_string(jv), label);
BEAST_EXPECTS(reply[jss::error][jss::error] == token, label);
BEAST_EXPECTS(reply[jss::error][jss::error_code] == expected, label);
BEAST_EXPECTS(resp.result() == kBadRequest, label);
}
// `submit` reports `invalidTransaction` with a coded error, and adds `error_exception`
// carrying the underlying failure detail.
{
Response resp;
json::Value jv;
jv[jss::method] = "submit";
jv[jss::params] = json::ValueType::Array;
jv[jss::params][0u] = json::ValueType::Object;
jv[jss::params][0u][jss::tx_blob] = "DEADBEEF";
jv[jss::params][0u][jss::ripplerpc] = rpc::kRippleRpcVersion3;
auto const reply = postAndParse(env, yield, resp, ec, to_string(jv));
auto const& error = reply[jss::error];
BEAST_EXPECT(error[jss::error] == "invalidTransaction");
BEAST_EXPECT(error[jss::error_code] == RpcInvalidTransaction);
BEAST_EXPECT(error[jss::message] == "Transaction is invalid.");
BEAST_EXPECT(!error[jss::error_exception].asString().empty());
BEAST_EXPECT(resp.result() == kBadRequest);
}
}
/**
* The `ripplerpc: "3.0"` envelope reports 200 for the codes below.
*
* `account_info` on an account the ledger does not hold is a routine call,
* and a 4xx there turns a working reply into a failure for anything that
* fails over on one. The status each code names in the table is pinned in
* the `ErrorCodes` gtest.
*
* @param yield The coroutine the requests run on.
*/
void
testGainedStatusesStayOffLegacyEnvelope(boost::asio::yield_context& yield)
{
testcase("A code that gained an HTTP status keeps 200 on the legacy envelope");
using namespace test::jtx;
Env env{*this, envconfig([](std::unique_ptr<Config> cfg) {
cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue");
return cfg;
})};
Account const alice{"alice"};
// Never funded, so the ledger holds no entry for it.
Account const absent{"absent"};
env.fund(XRP(10000), alice);
env.close();
boost::system::error_code ec;
// Accepted by the signing checks, so the request reaches the two conditions below.
// `sign_for` fills nothing in, so the fee and sequence are named here.
auto const accountSet = [&env, &alice] {
json::Value tx;
tx[jss::Account] = alice.human();
tx[jss::TransactionType] = jss::AccountSet;
tx[jss::SigningPubKey] = "";
tx[jss::Fee] = (8 * env.current()->fees().base).jsonClipped();
tx[jss::Sequence] = env.seq(alice);
return tx;
};
auto const account = [](std::string_view ident) {
json::Value params(json::ValueType::Object);
params[jss::account] = ident;
return params;
};
// `sign` single-signs, so a transaction already carrying `Signers` is multisigned.
json::Value multisigned(json::ValueType::Object);
multisigned[jss::secret] = toBase58(generateSeed("alice"));
multisigned[jss::tx_json] = accountSet();
multisigned[jss::tx_json][jss::Signers] = json::ValueType::Array;
// `sign_for` multisigns, so a transaction already carrying `TxnSignature` is single-signed.
json::Value singleSigned(json::ValueType::Object);
singleSigned[jss::account] = alice.human();
singleSigned[jss::secret] = toBase58(generateSeed("alice"));
singleSigned[jss::tx_json] = accountSet();
singleSigned[jss::tx_json][jss::TxnSignature] = "DEADBEEF";
struct Case
{
char const* method;
json::Value params;
ErrorCodeI code;
};
for (auto& [method, params, code] : {
Case{
.method = "account_info", .params = account("bogus"), .code = RpcActMalformed},
Case{
.method = "account_info",
.params = account(absent.human()),
.code = RpcActNotFound},
Case{.method = "sign", .params = multisigned, .code = RpcAlreadyMultisig},
Case{.method = "sign_for", .params = singleSigned, .code = RpcAlreadySingleSig},
})
{
json::Value jv;
jv[jss::method] = method;
jv[jss::params] = json::ValueType::Array;
jv[jss::params][0u] = params;
jv[jss::params][0u][jss::ripplerpc] = rpc::kRippleRpcVersion3;
Response resp;
auto const& info = rpc::getErrorInfo(code);
auto const label = std::string{info.token.cStr()};
auto const reply = postAndParse(env, yield, resp, ec, to_string(jv), label);
BEAST_EXPECTS(reply[jss::error][jss::error] == info.token.cStr(), label);
BEAST_EXPECTS(reply[jss::error][jss::error_code] == code, label);
// The code names a status the envelope declines to report, so the two disagree here
// by design.
BEAST_EXPECTS(info.httpStatus != 200, label);
BEAST_EXPECTS(resp.result() == kOk, label);
}
}
/**
* A credential the server echoes back is masked, on every path that echoes.
*
@@ -1719,6 +1989,8 @@ public:
testTheLoggedReplyIsMaskedOnlyWhenItCarriesACredential(yield);
testInternalErrorIsReportedOnBothTransports(yield);
testNoCredentialReachesTheLogAtTrace(yield);
testHandlerErrorsCarryCodes(yield);
testGainedStatusesStayOffLegacyEnvelope(yield);
testStatusNotOkay(yield);
});

View File

@@ -0,0 +1,71 @@
#include <xrpl/protocol/ErrorCodes.h>
#include <gtest/gtest.h>
#include <set>
#include <type_traits>
using namespace xrpl;
// A row cannot be written without a status, so no row can default to 200 again.
static_assert(!std::is_constructible_v<rpc::ErrorInfo, ErrorCodeI, char const*, char const*>);
namespace {
/**
* The numbers between `RpcBadSyntax` and `RpcLast` that have no row in the
* error table: those no enumerator uses, and `RpcReportingUnsupported` (91),
* the one enumerator the table does not list.
*/
std::set<int> const kGaps{5, 8, 20, 24, 25, 26, 27, 28, 34, 38, 39, 54, 55, 56,
59, 60, 61, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91};
} // namespace
TEST(ErrorCodes, table_gaps_report_the_unknown_placeholder)
{
// Codes absent from the table report the placeholder, so a scan by code must skip them.
auto const& gap = rpc::getErrorInfo(static_cast<ErrorCodeI>(5));
EXPECT_EQ(gap.code, RpcUnknown);
EXPECT_STREQ(gap.token.cStr(), "unknown");
auto const& real = rpc::getErrorInfo(RpcInvalidParams);
EXPECT_EQ(real.code, RpcInvalidParams);
EXPECT_STREQ(real.token.cStr(), "invalidParams");
}
TEST(ErrorCodes, exactly_the_listed_codes_have_no_row)
{
// The gap set is written out by hand, so an enumerator added without a row fails here, where a
// test asking the table which codes it knows would take the missing row for a gap.
for (int code = RpcBadSyntax; code <= RpcLast; ++code)
{
bool const isGap = rpc::getErrorInfo(static_cast<ErrorCodeI>(code)).code == RpcUnknown;
EXPECT_EQ(isGap, kGaps.contains(code)) << "code " << code;
}
}
TEST(ErrorCodes, every_code_names_an_http_status)
{
// Written out here rather than read from the row: a test reading the row production reads
// cannot tell whether the row is right.
EXPECT_EQ(rpc::errorCodeHttpStatus(RpcActMalformed), 400);
EXPECT_EQ(rpc::errorCodeHttpStatus(RpcActNotFound), 404);
EXPECT_EQ(rpc::errorCodeHttpStatus(RpcAlreadyMultisig), 400);
EXPECT_EQ(rpc::errorCodeHttpStatus(RpcAlreadySingleSig), 400);
// Every row names a status of its own. A code with no row reports the placeholder's, which the
// default constructor fixes at 200.
for (int code = RpcBadSyntax; code <= RpcLast; ++code)
{
auto const status = rpc::errorCodeHttpStatus(static_cast<ErrorCodeI>(code));
if (kGaps.contains(code))
{
EXPECT_EQ(status, 200) << "code " << code;
}
else
{
EXPECT_NE(status, 200) << "code " << code;
}
}
}

View File

@@ -598,6 +598,31 @@ constexpr json::Int kServerOverloaded = -32604;
constexpr json::Int kForbidden = -32605;
constexpr json::Int kWrongVersion = -32606;
/**
* The HTTP status the `ripplerpc: "3.0"` envelope reports for @p code.
*
* The codes below answer 200, which is what a client calling `account_info`
* this way already reads. The list is closed: a code belongs on it only if it
* already answered a coded reply at this status.
*
* @param code The error code the reply reports.
* @return The HTTP status the reply is sent with.
*/
static int
legacyHttpStatus(ErrorCodeI code)
{
switch (code)
{
case RpcActMalformed:
case RpcActNotFound:
case RpcAlreadyMultisig:
case RpcAlreadySingleSig:
return 200;
default:
return rpc::errorCodeHttpStatus(code);
}
}
void
ServerHandler::processRequest(
Port const& port,
@@ -970,7 +995,7 @@ ServerHandler::processRequest(
reply[jss::error][jss::error_code].isInt())
{
int const errCode = reply[jss::error][jss::error_code].asInt();
return rpc::errorCodeHttpStatus(static_cast<ErrorCodeI>(errCode));
return legacyHttpStatus(static_cast<ErrorCodeI>(errCode));
}
}
// Return OK.

View File

@@ -4,6 +4,7 @@
#include <xrpld/rpc/Context.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/server/NetworkOPs.h>
@@ -11,6 +12,13 @@
namespace xrpl {
/**
* Closes the open ledger on a stand-alone server.
*
* @param context The request. Its `params` are not read.
* @return `ledger_current_index` of the ledger the close opens, or
* `notStandAlone` when the server is on a network.
*/
json::Value
doLedgerAccept(rpc::JsonContext& context)
{
@@ -18,7 +26,7 @@ doLedgerAccept(rpc::JsonContext& context)
if (!context.app.config().standalone())
{
jvResult[jss::error] = "notStandAlone";
rpc::injectError(RpcNotStandAlone, jvResult);
}
else
{

View File

@@ -1038,7 +1038,7 @@ doLedgerEntry(rpc::JsonContext& context)
{
if (context.apiVersion < 2u)
{
jvResult[jss::error] = "unknownOption";
rpc::injectError(RpcUnknownOption, jvResult);
return jvResult;
}
return rpc::makeParamError("No ledger_entry params provided.");

View File

@@ -24,6 +24,9 @@
namespace xrpl::ledger_entry_helpers {
// These helpers name the malformed field in the `error` token and report `invalidParams` as the
// code, whatever the token is. A client has read 31 for every one of these tokens for years, so the
// code is deliberately not derived from the token here.
inline std::unexpected<json::Value>
missingFieldError(json::StaticString const field, std::optional<std::string> err = std::nullopt)
{

View File

@@ -42,6 +42,17 @@
namespace xrpl {
/**
* Picks the `Sequence` a simulated transaction runs with.
*
* @param txJson The transaction, which carries an `Account` member.
* @param context The request, read for the open ledger and the queue.
* @return 0 when the transaction names a `TicketSequence`, otherwise the
* account's next queueable sequence. On either path, the error to
* report when `Account` is not a string or does not parse as an
* account; without a `TicketSequence`, also when the account is not
* in the open ledger.
*/
static std::expected<std::uint32_t, json::Value>
getAutofillSequence(json::Value const& txJson, rpc::JsonContext& context)
{
@@ -50,10 +61,10 @@ getAutofillSequence(json::Value const& txJson, rpc::JsonContext& context)
auto const& accountStr = txJson[jss::Account];
if (!accountStr.isString())
{
// sanity check, should fail earlier
// LCOV_EXCL_START
// The earlier check requires `Account` to be present, not to be a string, so this is the
// type check. Without it `asString` below throws on a numeric value, which degrades a clean
// field error into an internal one.
return std::unexpected(rpc::invalidFieldError("tx.Account"));
// LCOV_EXCL_STOP
}
auto const srcAddressID = parseBase58<AccountID>(accountStr.asString());
if (!srcAddressID.has_value())
@@ -240,6 +251,19 @@ getTxJsonFromParams(json::Value const& params)
return txJson;
}
/**
* Applies @p transaction to a copy of the open ledger as a dry run.
*
* @param context The request, read for `binary`.
* @param transaction The transaction to apply.
* @return The engine result with `applied` and `ledger_index`, and the
* transaction as `tx_blob` when `binary` is true, otherwise as
* `tx_json`. Its metadata follows as `meta_blob` or `meta` only
* when the engine produced any, which it does when the result is
* `tesSUCCESS` or a `tec` code, since both apply the transaction
* to the view. A `tel`, `tem`, `tef` or `ter` result applies
* nothing and carries no metadata.
*/
static json::Value
simulateTxn(rpc::JsonContext& context, std::shared_ptr<Transaction> transaction)
{
@@ -266,12 +290,11 @@ simulateTxn(rpc::JsonContext& context, std::shared_ptr<Transaction> transaction)
}
else
{
// shouldn't be hit
// LCOV_EXCL_START
// Every TER this can hold names a token, so this arm states the fallback rather than a
// result any transaction reaches.
jvResult[jss::engine_result] = "unknown";
jvResult[jss::engine_result_code] = result.ter;
jvResult[jss::engine_result_message] = "unknown";
// LCOV_EXCL_STOP
}
if (token == "tesSUCCESS")
@@ -307,10 +330,20 @@ simulateTxn(rpc::JsonContext& context, std::shared_ptr<Transaction> transaction)
return jvResult;
}
// {
// tx_blob: <string> XOR tx_json: <object>,
// binary: <bool>
// }
/**
* Dry-runs a transaction against the open ledger without submitting it.
*
* `params` carry `tx_blob` or `tx_json`, one of the two, and optionally
* `binary`. A credential in `params` is refused. A `tx_json` missing
* `Sequence`, `Fee` or `SigningPubKey` has it filled in, and one missing
* `NetworkID` has it filled in where the network's ID is above 1024.
*
* @param context The request.
* @return The result `simulateTxn` builds, or an error object:
* `invalidTransaction` with `error_exception` when the transaction
* does not build, `internalSimulate` with `error_exception` when
* applying it throws.
*/
json::Value
doSimulate(rpc::JsonContext& context)
{
@@ -353,7 +386,7 @@ doSimulate(rpc::JsonContext& context)
catch (std::exception& e)
{
json::Value jvResult = json::ValueType::Object;
jvResult[jss::error] = "invalidTransaction";
rpc::injectError(RpcInvalidTransaction, jvResult);
jvResult[jss::error_exception] = e.what();
return jvResult;
}
@@ -377,15 +410,13 @@ doSimulate(rpc::JsonContext& context)
{
return simulateTxn(context, transaction);
}
// LCOV_EXCL_START this is just in case, so xrpld doesn't crash
catch (std::exception const& e)
{
json::Value jvResult = json::ValueType::Object;
jvResult[jss::error] = "internalSimulate";
rpc::injectError(RpcInternalSimulate, jvResult);
jvResult[jss::error_exception] = e.what();
return jvResult;
}
// LCOV_EXCL_STOP
}
} // namespace xrpl

View File

@@ -37,10 +37,21 @@ getFailHard(rpc::JsonContext const& context)
context.params.isMember(jss::fail_hard) && context.params[jss::fail_hard].asBool());
}
// {
// tx_blob: <string> XOR tx_json: <object>,
// secret: <secret>
// }
/**
* Submits a transaction to the network.
*
* `params` carry `tx_blob`, a signed transaction, or `tx_json` with a
* signing credential, which the server signs first when it allows signing;
* that path is deprecated. `fail_hard` keeps a transaction the local checks
* fail from being relayed.
*
* @param context The request.
* @return The engine result and the transaction, or an error object:
* `invalidTransaction` with `error_exception` when the blob does not
* decode or fails the local checks, `internalSubmit` with
* `error_exception` when processing throws, `internalJson` when
* building the reply throws.
*/
json::Value
doSubmit(rpc::JsonContext& context)
{
@@ -90,7 +101,7 @@ doSubmit(rpc::JsonContext& context)
}
catch (std::exception& e)
{
jvResult[jss::error] = "invalidTransaction";
rpc::injectError(RpcInvalidTransaction, jvResult);
jvResult[jss::error_exception] = e.what();
return jvResult;
@@ -106,7 +117,7 @@ doSubmit(rpc::JsonContext& context)
context.app.getHashRouter(), *stTx, context.ledgerMaster.getCurrentLedger()->rules());
if (validity != Validity::Valid)
{
jvResult[jss::error] = "invalidTransaction";
rpc::injectError(RpcInvalidTransaction, jvResult);
jvResult[jss::error_exception] = "fails local checks: " + reason;
return jvResult;
@@ -117,7 +128,7 @@ doSubmit(rpc::JsonContext& context)
auto transaction = std::make_shared<Transaction>(stTx, reason, context.app);
if (transaction->getStatus() != TransStatus::NEW)
{
jvResult[jss::error] = "invalidTransaction";
rpc::injectError(RpcInvalidTransaction, jvResult);
jvResult[jss::error_exception] = "fails local checks: " + reason;
return jvResult;
@@ -133,7 +144,7 @@ doSubmit(rpc::JsonContext& context)
}
catch (std::exception& e)
{
jvResult[jss::error] = "internalSubmit";
rpc::injectError(RpcInternalSubmit, jvResult);
jvResult[jss::error_exception] = e.what();
return jvResult;
@@ -179,7 +190,7 @@ doSubmit(rpc::JsonContext& context)
}
catch (std::exception& e)
{
jvResult[jss::error] = "internalJson";
rpc::injectError(RpcInternalJson, jvResult);
jvResult[jss::error_exception] = e.what();
return jvResult;

View File

@@ -9,6 +9,7 @@
#include <xrpl/basics/chrono.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/jss.h>
#include <rpcspec/Errors.hpp>
@@ -37,22 +38,23 @@ TransactionEntryHandler::process(Input const& input) const
if (!input.txHash &&
input.txHash.error() == ::rpc::spec::handlers::transaction_entry::TxHashError::Missing)
{
output.error = "fieldNotFoundTransaction";
output.error = ::rpc::Status{
RpcFieldNotFoundTransaction, missingFieldMessage(std::string{jss::tx_hash.cStr()})};
}
else if (output.ledger->open())
{
// We don't work on ledger current.
output.error = "notYetImplemented";
output.error = ::rpc::Status{RpcNotYetImplemented};
}
else if (!input.txHash)
{
output.error = "malformedRequest";
output.error = ::rpc::Status{RpcMalformedRequest};
}
else
{
std::tie(output.tx, output.meta) = output.ledger->txRead(*input.txHash);
if (!output.tx)
output.error = "transactionNotFound";
output.error = ::rpc::Status{RpcTransactionNotFound};
}
return output;
@@ -68,7 +70,9 @@ TransactionEntryHandler::writeResult(json::Value& value, Output const& output) c
if (output.error)
{
value[jss::error] = std::string{*output.error};
// The error travels in the Output, not as a failed process(), so the reply keeps the
// ledger fields written above beside the token, code and message.
injectSpecError(value, *output.error);
return;
}

View File

@@ -15,7 +15,6 @@
#include <functional>
#include <memory>
#include <optional>
#include <string_view>
namespace xrpl::rpc {
@@ -25,16 +24,38 @@ public:
struct Output
{
std::shared_ptr<ReadView const> ledger;
std::optional<std::string_view> error;
/**
* The error reported beside the ledger fields when the lookup fails.
*/
std::optional<::rpc::Status> error;
std::shared_ptr<STTx const> tx;
std::shared_ptr<STObject const> meta;
};
explicit TransactionEntryHandler(JsonContext&);
/**
* Looks the transaction up in the selected ledger.
*
* @param input The parsed request: the ledger selected, and the `tx_hash`
* read or the reason there is none.
* @return The ledger with the transaction and its metadata, or the ledger
* with an error carrying its code: `fieldNotFoundTransaction`
* without a `tx_hash`, `notYetImplemented` when the ledger is the
* open one, `malformedRequest` when `tx_hash` is not a hex string,
* `transactionNotFound` when the ledger does not hold it. A ledger
* that cannot be selected is returned as the unexpected Status.
*/
[[nodiscard]] std::expected<Output, ::rpc::Status>
process(Input const& input) const;
/**
* Writes the reply: the ledger fields, then either the error with its
* code and message, or the transaction and its metadata.
*
* @param value The reply object written into.
* @param output The result of `process`.
*/
void
writeResult(json::Value& value, Output const& output) const;