Compare commits

...

26 Commits

Author SHA1 Message Date
Richard Holland
de7afa9895 Merge branch 'dev' into jsontx 2026-10-08 23:12:33 +10:00
Richard Holland
983c5dfe0c fix consensus ordering for sfTime 2026-10-09 00:12:04 +11:00
tequ
47b00e9a65 Merge branch 'dev' into jsontx 2026-10-07 15:48:17 +09:00
Richard Holland
ee1d44f7d6 Merge branch 'dev' into jsontx 2026-10-04 10:52:56 +10:00
Richard Holland
5995cc2d00 Merge branch 'dev' into jsontx 2026-10-03 09:46:40 +10:00
Richard Holland
e9cc9e6fcb edge cases for seq=0, update fix name 2026-10-01 08:28:38 +10:00
Richard Holland
350347c615 Merge branch 'dev' into jsontx 2026-10-01 05:43:23 +10:00
Richard Holland
7112b80d8f Merge branch 'dev' into jsontx 2026-09-30 14:20:36 +10:00
Richard Holland
3de9cc3ba7 Merge branch 'dev' into jsontx 2026-09-29 04:38:01 +10:00
Richard Holland
e35ae4af20 seq=0 safety 2026-09-29 04:37:36 +10:00
Richard Holland
d916f108da implied seq=0 when time present or ticketseq 2026-09-29 04:20:57 +10:00
Richard Holland
a09c180a93 sfTime fixes, integer bounds, etc 2026-09-29 03:58:14 +10:00
Richard Holland
33939f0b02 improvements 2026-09-26 20:50:30 +10:00
Richard Holland
779a07804b Merge branch 'dev' into jsontx 2026-09-26 16:16:01 +10:00
Richard Holland
60caaea495 try again 2026-09-13 23:40:06 +10:00
RichardAHBot
4bbcc2d953 fix: regenerate hook/sfcodes.h on jsontx branch
Adds sfTime (UINT32/96), sfJsonTxDelta (VL/96), and sfAppLoader/sfAppLoaderID
which were added to sfields.macro but not propagated to the generated hook
header, causing verify-generated-headers CI failure.
2026-09-13 22:39:31 +10:00
RichardAHBot
72d793d912 style: clang-format-18 for jsontx files 2026-09-13 22:39:31 +10:00
RichardAHBot
db25f9799c test: add 15 more error path tests for delta decoder and timestamp parser
Cover unsanitize_jsontx error paths: truncated delta, unmerged literal/run,
empty delta, undersize copy, unmerged copy run, bad literal length, overlong
varint, and delta value out of range. Also add timestamp edge cases: malformed
ISO format, out-of-range month/day, non-leap Feb 29, and before-epoch dates.
2026-09-13 22:39:31 +10:00
RichardAHBot
e470d8a70b fix: tighten jsontx_exact integer boundary at 2^53
jsontx_exact: change the double boundary check from exclusive to inclusive
(d > max -> d >= max, d < -max -> d <= -max). Doubles cannot uniquely
represent odd integers at or above 2^53, so values like 2^53+1 silently
round to 2^53, corrupting the canonical form. The safe ceiling for
round-trip-exact integers is 2^53 - 1.

tests: add coverage for the 2^53 boundary (positive and negative sides,
and the silent rounding case).
2026-09-13 22:39:31 +10:00
RichardAHBot
4fb88f5f80 fix: reject \u escapes in jsontx_strict; expand tests
jsontx_strict: the comment claimed \u sequences were rejected but the
code only tracked backslashes without checking if they introduced a
unicode escape. Now properly throws on \u inside strings, which also
covers NUL-byte injection (\u0000) and prevents canonical form
mismatches caused by jsoncpp silently decoding \uXXXX.

tests: add coverage for \u rejection, jsontx_u64 negative/non-integer/
infinity rejection, delta size scaling with transaction complexity,
and sanitize rejection of unicode escapes.

Fixes a subtle security concern: without this check, a signer could
send a preimage with \uXXXX that jsoncpp would decode into a
different character, causing the canonical form to diverge from what
the signer actually signed while still passing the delta round-trip.
2026-09-13 22:39:31 +10:00
RichardAHBot
b2cdcf429e improve: add sig parameter check and expand json-tx test coverage
Submit.cpp: validate jss::sig presence before use so missing-signature
errors are reported clearly rather than as a misleading 'bad signature'.

JSONTxSignatures_test: 40 test sections covering:
- strict JSON parsing (comments, unicode, trailing commas, single quotes)
- ISO-8601 timestamp round-trips and overflow
- sanitize/unsanitize delta encoding round-trips
- unknown field and duplicate field rejection
- NUL byte rejection in string values
- document size limit enforcement
- nested objects and canonical field reordering
- delta decoding edge cases (varint bounds, too many ops, copy past end,
  unknown ops, non-minimal varints)
- multiple round-trip stability
- u64 formatting edge cases
2026-09-13 22:39:31 +10:00
RichardAHBot
3fd6ee472a add: unit tests for JSONTxSignatures
Comprehensive test coverage for jsontx_strict, jsontx_iso round-tripping,
sanitize/unsanitize delta encoding, jsontx_num edge cases, jsontx_field
lookup, and full pipeline integration tests.

Tests cover:
- Valid and invalid strict JSON parsing (comments, unicode, syntax)
- ISO-8601 timestamp round-trips and overflow rejection
- Delta encoding round-trips for various transaction shapes
- Bounds checking on malformed deltas
- Multiple round-trip stability
- Whitespace normalization producing identical canonical forms
- Delta size bounded by jsontx_max_diff
2026-09-13 22:39:31 +10:00
Richard Holland
b2aeb1cd92 more 2026-09-11 19:06:34 +10:00
Richard Holland
0f56d2d807 Merge branch 'dev' into jsontx 2026-09-11 16:18:41 +10:00
Richard Holland
e904290e4e jsontx stuff 2026-08-05 12:43:25 +10:00
Richard Holland
723103150c init jsontxsig amendment 2026-07-31 12:36:39 +10:00
27 changed files with 4727 additions and 43 deletions

View File

@@ -105,6 +105,8 @@
#define sfMPTAmount ((3U << 16U) + 26U)
#define sfIssuerNode ((3U << 16U) + 27U)
#define sfSubjectNode ((3U << 16U) + 28U)
#define sfLastTxnTime ((3U << 16U) + 95U)
#define sfTime ((3U << 16U) + 96U)
#define sfTouchCount ((3U << 16U) + 97U)
#define sfAccountIndex ((3U << 16U) + 98U)
#define sfAccountCount ((3U << 16U) + 99U)
@@ -222,6 +224,7 @@
#define sfProvider ((7U << 16U) + 30U)
#define sfMPTokenMetadata ((7U << 16U) + 31U)
#define sfCredentialType ((7U << 16U) + 32U)
#define sfJsonTxDelta ((7U << 16U) + 96U)
#define sfHookName ((7U << 16U) + 97U)
#define sfRemarkValue ((7U << 16U) + 98U)
#define sfRemarkName ((7U << 16U) + 99U)

View File

@@ -0,0 +1,182 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2012-2014 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#ifndef RIPPLE_PROTOCOL_JSONTXSIGNATURES_H_INCLUDED
#define RIPPLE_PROTOCOL_JSONTXSIGNATURES_H_INCLUDED
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STTx.h>
#include <cstdint>
#include <string>
#include <string_view>
#include <utility>
namespace ripple {
//------------------------------------------------------------------------------
// jsontx: plaintext-JSON signing support (featureJsonTx)
//
// A JsonTx is authorised by an ed25519 signature over
//
// jsontx_sign_prefix || preimage
//
// where the preimage is the exact JSON text the signer read. The node rebuilds
// the preimage from the binary transaction plus sfJsonTxDelta, a small delta
// against a canonical form the node derives itself. The delta is
// attacker-controlled: it is not covered by the signature it helps
// reconstruct. Every bound below is therefore explicit, and unsanitize_jsontx
// accepts only the exact encoding sanitize_jsontx would have produced.
//
// CONSENSUS SURFACE. Once featureJsonTx activates, everything that decides
// whether a preimage verifies is a consensus rule and can only change behind
// a further amendment. That is:
//
// - every constant and every function in this file, including the delta
// encoder's exact output (jsontx_verify compares it byte for byte);
// - STObject::getJson(JsonOptions::none) and the getJson of every ST type
// that can appear in a transaction (STAmount, STUInt64, STPathSet,
// STIssue, STVector256, ...), because the node's canonical form is
// derived from it;
// - Json::FastWriter, which serializes that JSON before canonicalization;
// - STParsedJSON, only in so far as the submit RPC builds the transaction
// from the canonical form: a change there changes which preimages can be
// submitted, not which verify.
//
// None of that code was consensus-critical before. A change to how any field
// renders as JSON - including one merged from upstream rippled - changes
// which signatures verify and must be amendment-gated. JSONTxSignatures_test
// pins this for every field of every transaction format.
//------------------------------------------------------------------------------
inline constexpr std::size_t jsontx_max_text = 8192; // canonical and original
inline constexpr std::size_t jsontx_max_diff = 2048; // delta bytes
inline constexpr std::size_t jsontx_max_ops =
jsontx_max_diff / 2; // delta instructions
inline constexpr std::size_t jsontx_min_copy = 4; // encoder match threshold
inline constexpr std::size_t jsontx_max_cand = 64; // encoder candidate cap
inline constexpr std::size_t jsontx_max_depth = 32; // JSON nesting
// The largest magnitude a bare JSON integer may have: 2^53 - 1, the range
// RFC 8259 section 6 calls interoperable and RFC 7493 (I-JSON) section 2.2
// requires. Past it an IEEE 754 double - which is what JSON.parse and most
// other parsers produce - can no longer hold every integer, so the number a
// wallet shows after parsing the preimage may not be the number that
// executes. Larger values are written as strings, which is how getJson
// renders every amount and UInt64 anyway.
inline constexpr std::uint64_t jsontx_max_int = (std::uint64_t{1} << 53) - 1;
// Domain separation. Without it the signed message is bare JSON text, and any
// wallet feature that signs a user-visible text message with the account key
// (several do, over the raw bytes) could be driven to sign a live Payment
// presented as a "log in" message. 0xFF can never occur in UTF-8, so no text
// signer can produce these bytes; the rest follows HashPrefix convention.
inline constexpr std::string_view jsontx_sign_prefix{
"\xFF"
"JTX",
4};
// The exact message a JsonTx signer signs for `preimage`.
std::string
jsontx_signing_data(std::string_view preimage);
// ASCII-only case folding; never consults the locale.
std::string
jsontx_lower(std::string_view s);
// Case-insensitive field-name -> canonical SField, over the serializable
// fields doServerDefinitions publishes. sfInvalid if unknown.
SField const&
jsontx_field(std::string const& name);
// days from 1970-01-01 (Howard Hinnant's civil calendar algorithm)
constexpr std::int64_t
jsontx_days(int y, unsigned m, unsigned d)
{
y -= m <= 2;
std::int64_t const era = (y >= 0 ? y : y - 399) / 400;
unsigned const yoe = static_cast<unsigned>(y - era * 400);
unsigned const doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
unsigned const doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + doe - 719468;
}
inline constexpr std::int64_t jsontx_epoch_day = jsontx_days(2000, 1, 1);
static_assert(jsontx_epoch_day == 10957); // matches chrono.h epoch_offset
// 9999-12-31T23:59:59.999Z: past this toISOString() switches to expanded years
// and the fixed 24 character shape no longer holds
inline constexpr std::uint64_t jsontx_max_time =
(static_cast<std::uint64_t>(jsontx_days(9999, 12, 31) - jsontx_epoch_day) *
86400 +
86399) *
1000 +
999;
// Strict Date().toISOString() -> milliseconds since the ripple epoch.
std::uint64_t
jsontx_iso(std::string_view s);
// The exact inverse over [0, jsontx_max_time].
std::string
jsontx_iso_str(std::uint64_t ms);
// How STUInt64::getJson spells v for field f (hex, or base ten for
// sMD_BaseTen fields).
std::string
jsontx_u64_str(SField const& f, std::uint64_t v);
// Validates that `raw` is a JsonTx document: exactly one JSON object, nothing
// before or after it but whitespace, printable ASCII only, no escapes, no
// comments, no booleans or nulls, no duplicate keys, integers spelled
// -?(0|[1-9][0-9]*), never -0, and no larger in magnitude than
// jsontx_max_int, nesting at most jsontx_max_depth. Throws.
void
jsontx_strict(std::string_view raw);
// The canonical form of `raw` alone: whitespace stripped, field names in
// their Xahau spelling, members ordered by field code, numbers formatted per
// field type, and - at the root only - an omitted Sequence written as
// "Sequence":0 when the document has a Time or a TicketSequence. Throws on
// anything it cannot canonicalize.
std::string
jsontx_canonical(std::string_view raw);
// Returns { canonical, delta } where applying delta to canonical with
// unsanitize_jsontx reproduces `raw` byte for byte. Throws.
std::pair<std::string, std::string>
sanitize_jsontx(std::string_view raw);
// Applies an UNTRUSTED delta to a canonical form the node derived itself.
std::string
unsanitize_jsontx(std::string_view sanitized, std::string_view diff);
// The complete untrusted-side check, shared by the submit RPC and the
// relay/consensus path. Returns the reconstructed preimage or throws.
std::string
jsontx_verify(STTx const& stx, std::string_view diff);
// As above with the delta taken from sfJsonTxDelta. This is what
// checkValidity calls in place of STTx::checkSign for a transaction
// carrying a delta.
std::string
jsontx_verify(STTx const& stx);
} // namespace ripple
#endif

View File

@@ -154,6 +154,21 @@ std::size_t constexpr maxPriceScale = 20;
*/
std::size_t constexpr maxTrim = 25;
/** sfTime validity window (featureJsonTx).
A transaction carrying sfTime (milliseconds since the ripple epoch) is
accepted only while the parent ledger's close time lies within
[Time - txTimeMaxFuture, Time + txTimeMaxAge]. The upper bound is the
stand-in for LastLedgerSequence: once a validated ledger closes later
than Time + txTimeMaxAge the transaction can never apply. The lower bound
keeps the validity period of a signed transaction short and limits how
far ahead of the network a fast client clock can push sfLastTxnTime.
Both are consensus rules once featureJsonTx activates.
*/
std::uint64_t constexpr txTimeMaxAgeMs = 300'000;
std::uint64_t constexpr txTimeMaxFutureMs = 120'000;
} // namespace ripple
#endif

View File

@@ -101,6 +101,18 @@ public:
SeqProxy
getSeqProxy() const;
/** True if replay protection comes from sfTime rather than a sequence.
That is: sfTime is present, Sequence is 0 and there is no
TicketSequence. Such a transaction neither checks nor consumes the
account's Sequence; it must instead carry a Time strictly greater
than the account's sfLastTxnTime, and records its Time there. Its
SeqProxy is sequence(0), so anything that derives an object id from
the SeqProxy must use seqID() instead.
*/
bool
isTimeSequenced() const;
boost::container::flat_set<AccountID>
getMentionedAccounts() const;

View File

@@ -34,6 +34,7 @@
// If you add an amendment here, then do not forget to increment `numFeatures`
// in include/xrpl/protocol/Feature.h.
XRPL_FEATURE(JsonTx, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (20261005, Supported::yes, VoteBehavior::DefaultYes)
XRPL_FEATURE(EscrowDestinationCancel, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (20260929, Supported::yes, VoteBehavior::DefaultNo)

View File

@@ -263,6 +263,7 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
{sfCron, soeOPTIONAL},
{sfAMMID, soeOPTIONAL},
{sfManifestID, soeOPTIONAL},
{sfLastTxnTime, soeOPTIONAL},
}))
/** A ledger object which contains a list of object identifiers.

View File

@@ -153,6 +153,8 @@ TYPED_SFIELD(sfOutstandingAmount, UINT64, 25, SField::sMD_BaseTen|SFie
TYPED_SFIELD(sfMPTAmount, UINT64, 26, SField::sMD_BaseTen|SField::sMD_Default)
TYPED_SFIELD(sfIssuerNode, UINT64, 27)
TYPED_SFIELD(sfSubjectNode, UINT64, 28)
TYPED_SFIELD(sfLastTxnTime, UINT64, 95)
TYPED_SFIELD(sfTime, UINT64, 96)
TYPED_SFIELD(sfTouchCount, UINT64, 97)
TYPED_SFIELD(sfAccountIndex, UINT64, 98)
TYPED_SFIELD(sfAccountCount, UINT64, 99)
@@ -294,6 +296,7 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
TYPED_SFIELD(sfProvider, VL, 30)
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
TYPED_SFIELD(sfCredentialType, VL, 32)
TYPED_SFIELD(sfJsonTxDelta, VL, 96, SField::sMD_Default, SField::notSigning)
TYPED_SFIELD(sfHookName, VL, 97)
TYPED_SFIELD(sfRemarkValue, VL, 98)
TYPED_SFIELD(sfRemarkName, VL, 99)

View File

@@ -633,6 +633,7 @@ JSS(server_status); // out: NetworkOPs
JSS(server_version); // out: NetworkOPs
JSS(settle_delay); // out: AccountChannels
JSS(severity); // in: LogLevel
JSS(sig);
JSS(signature); // out: NetworkOPs, ChannelAuthorize
JSS(signature_verified); // out: ChannelVerify
JSS(signing_key); // out: NetworkOPs

View File

@@ -0,0 +1,992 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2012-2014 Ripple Labs Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <xrpl/basics/contract.h>
#include <xrpl/json/json_writer.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/PublicKey.h>
#include <algorithm>
#include <charconv>
#include <stdexcept>
#include <unordered_map>
#include <vector>
namespace ripple {
namespace {
[[noreturn]] void
fail(std::string const& why)
{
Throw<std::runtime_error>("jsontx: " + why);
}
// Not std::isdigit: that consults the locale, and every comparison in this
// file decides whether a signature verifies.
constexpr bool
digit(char c)
{
return c >= '0' && c <= '9';
}
// json's whitespace set, exactly
constexpr bool
space(char c)
{
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
//------------------------------------------------------------------------------
// The preimage parser.
//
// Not Json::Reader. The vendored jsoncpp reader accepts comments, stops once
// it has a root value rather than requiring end of input, keeps the last of
// two identical keys, and holds numbers as 32-bit ints or doubles - so a bare
// integer past 2^32 is refused outright and a fractional token is rounded by
// sscanf. Each of those is a way for the text a signer reads to differ from
// what executes, or for a valid transaction to be unsignable. This parser
// accepts a deliberately small language instead:
//
// - exactly one object, with only whitespace around it;
// - printable ASCII only (0x20-0x7E) inside strings, and no backslash at
// all. Every string a transaction renders through getJson is plain
// printable ASCII (hex, base58, decimal, currency codes, type names), and
// jsontx_verify requires the preimage's strings to equal those byte for
// byte, so nothing an escape or a non-ASCII byte could spell would ever
// verify. Refusing them outright also rules out bidi overrides,
// homoglyphs and terminal control sequences in the text being approved;
// - integers spelled -?(0|[1-9][0-9]*), never -0, and within
// +/-jsontx_max_int, kept as their exact digits rather than converted to
// anything;
// - no true, false or null: no transaction field renders as one;
// - no two members of one object with the same name.
//------------------------------------------------------------------------------
struct Node
{
enum class Kind : std::uint8_t { object, array, string, number };
Kind kind = Kind::object;
std::string text; // string contents, or the number token
std::vector<std::string> keys; // object member names, in source order
std::vector<Node> items; // object member values, or array items
};
class Reader
{
std::string_view s_;
std::size_t p_ = 0;
void
skip()
{
while (p_ < s_.size() && space(s_[p_]))
++p_;
}
char
peek() const
{
if (p_ >= s_.size())
fail("unexpected end of document");
return s_[p_];
}
void
expect(char c, char const* what)
{
if (peek() != c)
fail(what);
++p_;
}
std::string
string()
{
++p_; // opening quote
std::size_t const b = p_;
for (;;)
{
if (p_ >= s_.size())
fail("unterminated string");
auto const c = static_cast<unsigned char>(s_[p_]);
if (c == '"')
break;
if (c == '\\')
fail("escape sequences are not allowed");
if (c < 0x20 || c > 0x7E)
fail("strings must be printable ASCII");
++p_;
}
std::string r(s_.substr(b, p_ - b));
++p_; // closing quote
return r;
}
std::string
number()
{
std::size_t const b = p_;
if (s_[p_] == '-')
++p_;
if (p_ >= s_.size() || !digit(s_[p_]))
fail("number must be a plain integer");
if (s_[p_] == '0')
++p_;
else
while (p_ < s_.size() && digit(s_[p_]))
++p_;
// a fraction, an exponent, a leading zero or anything else glued on
if (p_ < s_.size() && !space(s_[p_]) && s_[p_] != ',' &&
s_[p_] != '}' && s_[p_] != ']')
fail("number must be a plain integer");
std::string tok(s_.substr(b, p_ - b));
if (tok == "-0")
fail("number must be a plain integer");
// The interoperable range, symmetric about zero. Sixteen digits is
// the most 2^53 - 1 has, so the length test also keeps from_chars
// well away from overflow.
std::string_view const mag =
std::string_view(tok).substr(tok.front() == '-' ? 1 : 0);
std::uint64_t v = 0;
if (mag.size() > 16 ||
std::from_chars(mag.data(), mag.data() + mag.size(), v).ec !=
std::errc{} ||
v > jsontx_max_int)
fail(
"integer outside +/-(2^53 - 1); write larger values as "
"strings");
return tok;
}
void
value(Node& n, std::size_t depth)
{
switch (peek())
{
case '{':
object(n, depth + 1);
return;
case '[':
array(n, depth + 1);
return;
case '"':
n.kind = Node::Kind::string;
n.text = string();
return;
case 't':
case 'f':
fail("boolean values are not allowed");
case 'n':
fail("null values are not allowed");
case '/':
fail("comments are not allowed");
default:
if (peek() == '-' || digit(peek()))
{
n.kind = Node::Kind::number;
n.text = number();
return;
}
fail("unexpected character");
}
}
void
object(Node& n, std::size_t depth)
{
if (depth > jsontx_max_depth)
fail("document nested too deeply");
n.kind = Node::Kind::object;
++p_; // '{'
skip();
if (peek() != '}')
{
for (;;)
{
skip();
if (peek() != '"')
fail("member name must be a string");
n.keys.push_back(string());
skip();
expect(':', "expected ':'");
skip();
value(n.items.emplace_back(), depth);
skip();
if (peek() == ',')
{
++p_;
continue;
}
break;
}
}
expect('}', "expected ',' or '}'");
// Two identical names are one member to jsoncpp (the last wins) and
// may be either to whatever the signer's wallet parsed.
std::vector<std::string_view> ks(n.keys.begin(), n.keys.end());
std::sort(ks.begin(), ks.end());
if (std::adjacent_find(ks.begin(), ks.end()) != ks.end())
fail("duplicate member name");
}
void
array(Node& n, std::size_t depth)
{
if (depth > jsontx_max_depth)
fail("document nested too deeply");
n.kind = Node::Kind::array;
++p_; // '['
skip();
if (peek() != ']')
{
for (;;)
{
skip();
value(n.items.emplace_back(), depth);
skip();
if (peek() == ',')
{
++p_;
continue;
}
break;
}
}
expect(']', "expected ',' or ']'");
}
public:
explicit Reader(std::string_view s) : s_(s)
{
}
Node
parse()
{
Node root;
skip();
if (p_ >= s_.size() || s_[p_] != '{')
fail("document must be an object");
object(root, 1);
skip();
if (p_ != s_.size())
fail(
s_[p_] == '/' ? "comments are not allowed"
: "trailing data after document");
return root;
}
};
Node
parse(std::string_view raw)
{
if (raw.size() > jsontx_max_text)
fail("document too large");
return Reader(raw).parse();
}
//------------------------------------------------------------------------------
// Canonicalization, directed by field type.
//
// Keys of a transaction or inner object are SField names, matched
// case-insensitively and re-emitted in their canonical spelling, ordered by
// field code. A field's type decides what its value may be and how it is
// written. The handful of non-field objects a transaction renders (an issued
// amount, an Issue, a path step) have their keys sorted bytewise and hold
// only scalars.
//------------------------------------------------------------------------------
void
quoted(std::string& o, std::string_view s)
{
o += '"';
o += s;
o += '"';
}
std::uint64_t
unsignedValue(Node const& v, SField const& f)
{
std::uint64_t n = 0;
auto const& t = v.text;
auto const r = std::from_chars(t.data(), t.data() + t.size(), n);
if (t.front() == '-' || r.ec != std::errc{} || r.ptr != t.data() + t.size())
fail("'" + f.fieldName + "' is out of range");
return n;
}
char const*
kindName(Node::Kind k)
{
switch (k)
{
case Node::Kind::object:
return "an object";
case Node::Kind::array:
return "an array";
case Node::Kind::string:
return "a string";
case Node::Kind::number:
return "a number";
}
return "?"; // LCOV_EXCL_LINE
}
void
require(Node const& v, Node::Kind k, std::string const& what)
{
if (v.kind != k)
fail(what + " must be " + kindName(k) + ", not " + kindName(v.kind));
}
void
emitFields(Node const& n, std::string& o, bool root = false);
// A string or a number, where the ledger wants a string: numbers become the
// quoted spelling of their exact digits.
void
emitScalar(Node const& v, std::string& o, std::string const& what)
{
if (v.kind != Node::Kind::string && v.kind != Node::Kind::number)
fail(what + " must be a string or a number");
quoted(o, v.text);
}
// {"currency":..,"issuer":..,"value":..} and the like
void
emitPlain(Node const& n, std::string const& what, std::string& o)
{
require(n, Node::Kind::object, what);
std::vector<std::size_t> idx(n.keys.size());
for (std::size_t i = 0; i < idx.size(); ++i)
idx[i] = i;
std::sort(idx.begin(), idx.end(), [&n](auto a, auto b) {
return n.keys[a] < n.keys[b];
});
o += '{';
for (auto const i : idx)
{
if (o.back() != '{')
o += ',';
quoted(o, n.keys[i]);
o += ':';
emitScalar(n.items[i], o, what + "." + n.keys[i]);
}
o += '}';
}
void
emitField(SField const& f, Node const& v, std::string& o)
{
std::string const& name = f.fieldName;
switch (f.fieldType)
{
case STI_OBJECT:
case STI_XCHAIN_BRIDGE: // renders its members under field names
require(v, Node::Kind::object, "'" + name + "'");
emitFields(v, o);
return;
case STI_ARRAY:
require(v, Node::Kind::array, "'" + name + "'");
o += '[';
for (auto const& e : v.items)
{
if (o.back() != '[')
o += ',';
// each element is {"InnerFieldName": {...}}
require(e, Node::Kind::object, "an element of '" + name + "'");
if (e.keys.size() != 1)
fail(
"an element of '" + name +
"' must have exactly one member");
emitFields(e, o);
}
o += ']';
return;
case STI_PATHSET:
require(v, Node::Kind::array, "'" + name + "'");
o += '[';
for (auto const& path : v.items)
{
if (o.back() != '[')
o += ',';
require(path, Node::Kind::array, "a path in '" + name + "'");
o += '[';
for (auto const& step : path.items)
{
if (o.back() != '[')
o += ',';
emitPlain(step, "a path step in '" + name + "'", o);
}
o += ']';
}
o += ']';
return;
case STI_VECTOR256:
require(v, Node::Kind::array, "'" + name + "'");
o += '[';
for (auto const& e : v.items)
{
if (o.back() != '[')
o += ',';
require(e, Node::Kind::string, "an element of '" + name + "'");
quoted(o, e.text);
}
o += ']';
return;
case STI_AMOUNT:
case STI_ISSUE:
if (v.kind == Node::Kind::object)
emitPlain(v, "'" + name + "'", o);
else
emitScalar(v, o, "'" + name + "'");
return;
case STI_UINT8:
case STI_UINT16:
case STI_UINT32:
if (v.kind == Node::Kind::string) // e.g. TransactionType
{
quoted(o, v.text);
return;
}
require(v, Node::Kind::number, "'" + name + "'");
{
std::uint64_t const max = f.fieldType == STI_UINT8 ? 0xFFu
: f.fieldType == STI_UINT16 ? 0xFFFFu
: 0xFFFF'FFFFu;
if (unsignedValue(v, f) > max)
fail("'" + name + "' is out of range");
}
o += v.text; // already minimal: the parser refuses leading zeros
return;
case STI_UINT64:
if (f == sfTime)
{
// Spelled Date().toISOString() in the preimage and stored as
// milliseconds. Re-emitting the round-tripped spelling rather
// than the input is what makes this a fixed point.
require(v, Node::Kind::string, "'" + name + "'");
quoted(o, jsontx_iso_str(jsontx_iso(v.text)));
return;
}
if (v.kind == Node::Kind::string)
{
quoted(o, v.text);
return;
}
require(v, Node::Kind::number, "'" + name + "'");
quoted(o, jsontx_u64_str(f, unsignedValue(v, f)));
return;
default: // hashes, blobs, accounts, currencies, numbers
emitScalar(v, o, "'" + name + "'");
return;
}
}
void
emitFields(Node const& n, std::string& o, bool root)
{
std::vector<std::pair<SField const*, Node const*>> ks;
ks.reserve(n.keys.size() + 1);
for (std::size_t i = 0; i < n.keys.size(); ++i)
{
auto const& f = jsontx_field(n.keys[i]);
if (f == sfInvalid)
fail("unknown field '" + n.keys[i] + "'");
ks.emplace_back(&f, &n.items[i]);
}
// An omitted Sequence is Sequence 0 when something else sequences the
// transaction: a Time (time-sequenced) or a TicketSequence. Both need
// Sequence 0, and neither can mean anything else by it. The canonical
// form always carries the field, because the node's own rendering does:
// getJson emits every required field. Written this way the two spellings
// of such a preimage - with and without "Sequence": 0 - canonicalize to
// the same bytes, and the delta simply skips the ones the signer left
// out.
//
// Only a constant may be implied here, never anything read from the
// ledger such as the account's next sequence: the transaction must be a
// pure function of the preimage, or the binding check means nothing.
//
// With neither field, nothing is implied: an absent Sequence is left
// absent, and the submit RPC refuses the document for want of one.
if (root)
{
auto const has = [&ks](SField const& f) {
return std::any_of(ks.begin(), ks.end(), [&f](auto const& k) {
return *k.first == f;
});
};
if (!has(sfSequence) && (has(sfTime) || has(sfTicketSequence)))
{
static Node const zero{Node::Kind::number, "0", {}, {}};
ks.emplace_back(&sfSequence, &zero);
}
}
// field codes are unique, so this order is total
std::sort(ks.begin(), ks.end(), [](auto const& a, auto const& b) {
return a.first->fieldCode < b.first->fieldCode;
});
o += '{';
for (std::size_t j = 0; j < ks.size(); ++j)
{
auto const& [f, v] = ks[j];
if (j && f == ks[j - 1].first) // e.g. "Fee" and "fee"
fail("duplicate field '" + f->fieldName + "'");
if (o.back() != '{')
o += ',';
quoted(o, f->fieldName);
o += ':';
emitField(*f, *v, o);
}
o += '}';
}
std::string
canonical(Node const& root)
{
std::string out;
emitFields(root, out, true);
// Usually far smaller than the input, but a bare number in a field the
// ledger wants as a string gains two quote characters, so a document of
// bare amounts can grow past the cap. unsanitize_jsontx refuses a
// canonical form that large, so refuse it here too.
if (out.size() > jsontx_max_text)
fail("canonical form too large");
return out;
}
void
varint(std::string& o, std::uint64_t v)
{
do
{
std::uint8_t const c = v & 0x7F;
v >>= 7;
o += static_cast<char>(c | (v ? 0x80 : 0));
} while (v);
}
} // namespace
//------------------------------------------------------------------------------
std::string
jsontx_signing_data(std::string_view preimage)
{
std::string r;
r.reserve(jsontx_sign_prefix.size() + preimage.size());
r += jsontx_sign_prefix;
r += preimage;
return r;
}
// Not boost::algorithm::to_lower_copy or std::tolower: both consult the global
// locale, under which a byte such as 0xC9 folds on one node and not another.
std::string
jsontx_lower(std::string_view s)
{
std::string r(s);
for (auto& c : r)
if (c >= 'A' && c <= 'Z')
c = static_cast<char>(c - 'A' + 'a');
return r;
}
SField const&
jsontx_field(std::string const& name)
{
static auto const tbl = [] {
std::unordered_map<std::string, SField const*> m;
for (auto const& [code, f] : SField::knownCodeToField)
if (f->isUseful() && f->isBinary() && f->fieldType < 10000 &&
!f->fieldName.empty())
{
// Two fields folding to one key would make the lookup depend
// on iteration order. None do today; keep it that way.
if (!m.emplace(jsontx_lower(f->fieldName), f).second)
LogicError(
"jsontx: field names collide case-insensitively: " +
f->fieldName);
}
return m;
}();
auto const i = tbl.find(jsontx_lower(name));
return i == tbl.end() ? sfInvalid : *i->second;
}
std::uint64_t
jsontx_iso(std::string_view s)
{
static constexpr char pat[] = "0000-00-00T00:00:00.000Z";
if (s.size() != 24)
fail("Time must be an ISO 8601 instant");
for (std::size_t i = 0; i < 24; ++i)
if (pat[i] == '0' ? !digit(s[i]) : s[i] != pat[i])
fail("malformed Time");
auto const n = [&s](std::size_t i, std::size_t c) {
int v = 0;
while (c--)
v = v * 10 + (s[i++] - '0');
return v;
};
int const y = n(0, 4), mo = n(5, 2), d = n(8, 2), h = n(11, 2),
mi = n(14, 2), se = n(17, 2), ms = n(20, 3);
if (mo < 1 || mo > 12)
fail("Time month out of range");
bool const leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
int const dim =
mo == 2 ? (leap ? 29 : 28) : ((mo % 2 == 1) == (mo <= 7) ? 31 : 30);
// 60 is refused: JS cannot emit a leap second and the ledger cannot
// represent one
if (d < 1 || d > dim || h > 23 || mi > 59 || se > 59)
fail("Time out of range");
std::int64_t const t = (jsontx_days(y, mo, d) - jsontx_epoch_day) * 86400 +
h * 3600 + mi * 60 + se;
if (t < 0)
fail("Time precedes the ripple epoch");
return static_cast<std::uint64_t>(t) * 1000 + ms;
}
std::string
jsontx_iso_str(std::uint64_t ms)
{
if (ms > jsontx_max_time)
fail("Time out of range");
std::int64_t const z =
static_cast<std::int64_t>(ms / 86400000) + jsontx_epoch_day + 719468;
unsigned const tod = static_cast<unsigned>(ms / 1000 % 86400);
std::int64_t const era = (z >= 0 ? z : z - 146096) / 146097;
unsigned const doe = static_cast<unsigned>(z - era * 146097);
unsigned const yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
unsigned const doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
unsigned const mp = (5 * doy + 2) / 153;
unsigned const d = doy - (153 * mp + 2) / 5 + 1;
unsigned const m = mp + (mp < 10 ? 3 : -9);
std::int64_t const y =
static_cast<std::int64_t>(yoe) + era * 400 + (m <= 2);
// jsontx_max_time bounds y to [2000, 9999], so every field fits its
// width. Hand-rolled: this spelling is part of the signed preimage and a
// library's padding rules are not.
char buf[24];
auto const pad = [&buf](std::size_t at, std::uint64_t v, std::size_t w) {
while (w--)
{
buf[at + w] = static_cast<char>('0' + v % 10);
v /= 10;
}
};
pad(0, static_cast<std::uint64_t>(y), 4);
buf[4] = '-';
pad(5, m, 2);
buf[7] = '-';
pad(8, d, 2);
buf[10] = 'T';
pad(11, tod / 3600, 2);
buf[13] = ':';
pad(14, tod / 60 % 60, 2);
buf[16] = ':';
pad(17, tod % 60, 2);
buf[19] = '.';
pad(20, ms % 1000, 3);
buf[23] = 'Z';
return std::string(buf, sizeof(buf));
}
std::string
jsontx_u64_str(SField const& f, std::uint64_t v)
{
char buf[20];
auto const r = std::to_chars(
buf, buf + sizeof(buf), v, f.shouldMeta(SField::sMD_BaseTen) ? 10 : 16);
return std::string(buf, r.ptr);
}
void
jsontx_strict(std::string_view raw)
{
(void)parse(raw);
}
std::string
jsontx_canonical(std::string_view raw)
{
return canonical(parse(raw));
}
std::pair<std::string, std::string>
sanitize_jsontx(std::string_view raw)
{
std::string out = canonical(parse(raw));
// Greedy copy/insert delta over `raw`, sourcing from `out`.
// op 0x00 <varint len> <bytes> literal
// op 0x01 <varint off> <varint len> copy from canonical
//
// This encoder is normative - unsanitize_jsontx only accepts its exact
// output - so it must emit identical bytes on every node and stdlib.
// Candidates for a 4-gram are tried in ascending offset order and ties
// keep the lowest offset. The index is a sorted vector of
// (gram << 32 | offset): offsets are unique, so the order is total and
// std::sort is deterministic.
std::string diff, lit;
std::size_t ops = 0;
auto const gram = [](std::string_view s, std::size_t i) {
return std::uint32_t(std::uint8_t(s[i])) << 24 |
std::uint32_t(std::uint8_t(s[i + 1])) << 16 |
std::uint32_t(std::uint8_t(s[i + 2])) << 8 |
std::uint32_t(std::uint8_t(s[i + 3]));
};
auto const flush = [&] {
if (lit.empty())
return;
diff += char(0);
varint(diff, lit.size());
diff += lit;
lit.clear();
++ops;
};
std::vector<std::uint64_t> idx;
if (out.size() >= jsontx_min_copy)
{
idx.reserve(out.size() - jsontx_min_copy + 1);
for (std::size_t i = 0; i + jsontx_min_copy <= out.size(); ++i)
idx.push_back(std::uint64_t(gram(out, i)) << 32 | i);
std::sort(idx.begin(), idx.end());
}
for (std::size_t i = 0; i < raw.size();)
{
std::size_t bo = 0, bl = 0;
if (i + jsontx_min_copy <= raw.size())
{
std::uint64_t const g = gram(raw, i);
auto it = std::lower_bound(idx.begin(), idx.end(), g << 32);
for (std::size_t tried = 0;
it != idx.end() && (*it >> 32) == g && tried < jsontx_max_cand;
++it, ++tried)
{
std::size_t const off = *it & 0xFFFF'FFFFu;
std::size_t l = 0;
while (i + l < raw.size() && off + l < out.size() &&
out[off + l] == raw[i + l])
++l;
if (l > bl)
bl = l, bo = off;
}
}
if (bl >= jsontx_min_copy)
{
flush();
diff += char(1);
varint(diff, bo);
varint(diff, bl);
++ops;
i += bl;
}
else
lit += raw[i++];
}
flush();
// The bounds unsanitize_jsontx applies, applied here so that a document
// this accepts is never one the verifier then refuses.
if (diff.size() > jsontx_max_diff || ops > jsontx_max_ops)
fail("formatting differs too much from canonical form");
return {std::move(out), std::move(diff)};
}
// Copies read only from `sanitized`, never from the output being built, so a
// short delta cannot expand geometrically. Offsets and lengths are range
// checked before use, varints are length- and minimality-bounded, and the two
// encodings the encoder can never emit - an unmerged literal run, and a copy
// abutting the previous copy in the source - are refused.
std::string
unsanitize_jsontx(std::string_view sanitized, std::string_view diff)
{
if (sanitized.size() > jsontx_max_text || diff.size() > jsontx_max_diff)
fail("oversize delta input");
std::string out;
std::size_t p = 0, ops = 0, prevEnd = 0;
int prev = -1;
auto const read = [&](std::uint64_t max) -> std::uint64_t {
std::uint64_t v = 0;
for (int s = 0; s <= 21; s += 7) // four bytes at most
{
if (p >= diff.size())
fail("truncated delta");
std::uint8_t const c = diff[p++];
v |= std::uint64_t(c & 0x7F) << s;
if (c & 0x80)
continue;
if (s && !(c & 0x7F))
fail("non-minimal varint");
if (v > max)
fail("delta value out of range");
return v;
}
fail("overlong varint");
};
while (p < diff.size())
{
if (++ops > jsontx_max_ops)
fail("too many delta ops");
std::uint8_t const op = diff[p++];
if (op > 1)
fail("unknown delta op");
if (op == 0) // literal
{
if (prev == 0)
fail("unmerged literal run");
auto const n = read(jsontx_max_text);
if (n == 0 || n > diff.size() - p)
fail("bad literal length");
if (out.size() + n > jsontx_max_text)
fail("delta expands too far");
out += diff.substr(p, n);
p += n;
}
else // copy from the canonical form
{
auto const off = read(sanitized.size());
auto const n = read(sanitized.size() - off);
if (n < jsontx_min_copy)
fail("undersize copy");
if (prev == 1 && off == prevEnd)
fail("unmerged copy run");
if (out.size() + n > jsontx_max_text)
fail("delta expands too far");
out += sanitized.substr(off, n);
prevEnd = off + n;
}
prev = op;
}
if (out.empty())
fail("empty delta");
return out;
}
std::string
jsontx_verify(STTx const& stx, std::string_view diff)
{
// The cheap refusals first: this runs on every relay of a transaction
// carrying a delta, before any signature is known to be good.
if (!stx.isFieldPresent(sfTxnSignature) || stx.isFieldPresent(sfSigners))
fail("expects a lone TxnSignature");
auto const pkb = stx.getSigningPubKey();
if (publicKeyType(makeSlice(pkb)) != KeyType::ed25519)
fail("SigningPubKey must be ed25519");
if (diff.size() > jsontx_max_diff)
fail("oversize delta");
// No transaction serializes to more than twice its canonical text
// (JSONTxSignatures_test checks every field of every format; the worst
// is a bare three-letter currency code, about 1.3x). Allowing twice that
// again, plus the delta and signature this counts but the canonical form
// does not, loses nothing that could verify, and spares getJson on an
// arbitrarily large object.
if (stx.getSerializer().size() > 4 * jsontx_max_text + jsontx_max_diff)
fail("transaction too large");
// Out comes everything the signer did not have in front of them: the
// signature and the delta carrier. SigningPubKey stays; it being inside
// the preimage binds key to signature and stops a third party re-signing
// a captured preimage under their own key.
auto txj = stx.STObject::getJson(JsonOptions::none);
txj.removeMember(sfTxnSignature.fieldName);
txj.removeMember(sfJsonTxDelta.fieldName);
// sfTime is milliseconds on the wire and an ISO 8601 instant in the
// preimage: a bijection over the representable range, so the delta
// carries nothing for the field.
if (stx.isFieldPresent(sfTime))
txj[sfTime.fieldName] = jsontx_iso_str(stx.getFieldU64(sfTime));
// canonical form, derived only from data the node already holds
auto const san = jsontx_canonical(Json::FastWriter{}.write(txj));
// reconstruct the signed preimage under the caps above
auto const raw = unsanitize_jsontx(san, diff);
// The signature before the binding check purely for cost: the binding
// check re-canonicalizes and re-encodes, and without the key an attacker
// cannot get past this line. Both must pass.
if (!verify(
PublicKey(makeSlice(pkb)),
makeSlice(jsontx_signing_data(raw)),
makeSlice(stx.getFieldVL(sfTxnSignature))))
fail("signature does not verify");
// Bind the preimage to the transaction. This is the load-bearing check:
// a delta of pure literals can reconstruct ANY text, so without it every
// JsonTx signature the key ever produced would authorise this
// transaction. Comparing the delta too makes it a pure function of the
// preimage, which rules out a second delta reconstructing the same bytes
// and yielding a second valid transaction id.
auto const [san2, diff2] = sanitize_jsontx(raw);
if (san2 != san || diff2 != diff)
fail("preimage does not match transaction");
return raw;
}
std::string
jsontx_verify(STTx const& stx)
{
if (!stx.isFieldPresent(sfJsonTxDelta))
fail("no delta");
auto const delta = stx.getFieldVL(sfJsonTxDelta);
return jsontx_verify(
stx,
std::string_view(
reinterpret_cast<char const*>(delta.data()), delta.size()));
}
} // namespace ripple

View File

@@ -198,6 +198,13 @@ STTx::getSeqProxy() const
return SeqProxy{SeqProxy::ticket, *ticketSeq};
}
bool
STTx::isTimeSequenced() const
{
return isFieldPresent(sfTime) && getFieldU32(sfSequence) == 0 &&
!isFieldPresent(sfTicketSequence);
}
void
STTx::sign(PublicKey const& publicKey, SecretKey const& secretKey)
{
@@ -214,6 +221,15 @@ STTx::checkSign(
RequireFullyCanonicalSig requireCanonicalSig,
Rules const& rules) const
{
// sfJsonTxDelta is a non-signing field, so the binary signing hash does
// not cover it: a binary signature stays valid with any delta appended.
// Were that accepted, anyone relaying a binary-signed transaction could
// mint as many new transaction ids for it as there are deltas. A delta
// means the signature is over the JSON preimage, which jsontx_verify
// checks; a binary signature never authorises one.
if (isFieldPresent(sfJsonTxDelta))
return Unexpected("Binary signature cannot authorise a JsonTx.");
try
{
// Determine whether we're single- or multi-signing by looking

View File

@@ -49,6 +49,8 @@ TxFormats::TxFormats()
{sfNetworkID, soeOPTIONAL},
{sfHookParameters, soeOPTIONAL},
{sfHookName, soeOPTIONAL},
{sfTime, soeOPTIONAL},
{sfJsonTxDelta, soeOPTIONAL},
};
#pragma push_macro("UNWRAP")

1514
src/test/app/JsonTx_test.cpp Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -161,6 +161,18 @@ public:
if (!sleAcct)
return false;
// A time-sequenced transaction's SeqProxy is sequence(0), which
// the sequence test below would call past and sweep at once. It
// is spent once the account has recorded its Time or a later one
// - applied, or superseded - and otherwise lives out holdLedgers
// like anything else (preclaim refuses it once it expires).
if (auto const time = txn.getTX()->at(~sfTime);
time && txn.getTX()->isTimeSequenced())
{
auto const last = sleAcct->at(~sfLastTxnTime);
return last && *last >= *time;
}
SeqProxy const acctSeq =
SeqProxy::sequence(sleAcct->getFieldU32(sfSequence));
SeqProxy const seqProx = txn.getSeqProxy();

View File

@@ -30,6 +30,11 @@ operator<(CanonicalTXSet::Key const& lhs, CanonicalTXSet::Key const& rhs)
if (lhs.account_ > rhs.account_)
return false;
// Empty for everything but a time-sequenced transaction, so this sorts
// those after the rest of the account's and leaves the rest alone.
if (lhs.time_ != rhs.time_)
return lhs.time_ < rhs.time_;
if (lhs.seqProxy_ < rhs.seqProxy_)
return true;
@@ -48,12 +53,28 @@ CanonicalTXSet::accountKey(AccountID const& account)
return ret;
}
CanonicalTXSet::Key
CanonicalTXSet::makeKey(
uint256 const& account,
STTx const& tx,
uint256 const& id)
{
return Key(
account,
tx.getSeqProxy(),
tx.isTimeSequenced()
? std::optional<std::uint64_t>(tx.getFieldU64(sfTime))
: std::nullopt,
id);
}
void
CanonicalTXSet::insert(std::shared_ptr<STTx const> const& txn)
{
map_.insert(std::make_pair(
Key(accountKey(txn->getAccountID(sfAccount)),
txn->getSeqProxy(),
makeKey(
accountKey(txn->getAccountID(sfAccount)),
*txn,
txn->getTransactionID()),
txn));
}
@@ -71,10 +92,13 @@ CanonicalTXSet::popAcctTransaction(std::shared_ptr<STTx const> const& tx)
//
// 3. After handling all transactions with Sequences, return Tickets
// with the lowest Ticket ID first.
//
// 4. After those, return time-sequenced transactions with the oldest
// Time first.
std::shared_ptr<STTx const> result;
uint256 const effectiveAccount{accountKey(tx->getAccountID(sfAccount))};
Key const after(effectiveAccount, tx->getSeqProxy(), beast::zero);
Key const after = makeKey(effectiveAccount, *tx, beast::zero);
auto const itrNext{map_.lower_bound(after)};
if (itrNext != map_.end() &&
itrNext->first.getAccount() == effectiveAccount)

View File

@@ -25,13 +25,31 @@
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/SeqProxy.h>
#include <optional>
namespace ripple {
/** Holds transactions which were deferred to the next pass of consensus.
"Canonical" refers to the order in which transactions are applied.
- Puts transactions from the same account in SeqProxy order
- Puts transactions from the same account in SeqProxy order: Sequences,
then Tickets
- Then the account's time-sequenced transactions (featureJsonTx), oldest
Time first
Without a time-sequenced transaction in the set this is the SeqProxy
order it always was. A Time on a transaction with a Sequence or a Ticket
changes nothing: only time-sequenced transactions check or record
sfLastTxnTime, so oldest first is all their order has to satisfy.
They go after the Tickets for the reason Tickets go after Sequences. A
time-sequenced transaction does not say which Sequence it followed, but
what it does can depend on it - a TicketCreate numbers its Tickets from
the account Sequence. A transaction with a Sequence or a Ticket that
really came after it fails terPRE_SEQ or terPRE_TICKET here and is
retried; one that came before it, applied second, could have its
Sequence or Ticket taken and fail for good.
*/
// VFALCO TODO rename to SortedTxSet
@@ -41,8 +59,11 @@ private:
class Key
{
public:
Key(uint256 const& account, SeqProxy seqProx, uint256 const& id)
: account_(account), txId_(id), seqProxy_(seqProx)
Key(uint256 const& account,
SeqProxy seqProx,
std::optional<std::uint64_t> time,
uint256 const& id)
: account_(account), txId_(id), seqProxy_(seqProx), time_(time)
{
}
@@ -95,6 +116,11 @@ private:
uint256 account_;
uint256 txId_;
SeqProxy seqProxy_;
// sfTime of a time-sequenced transaction, and nothing for any other.
// Compared before seqProxy_: an empty optional is less than every
// Time, so time-sequenced transactions come after the rest of their
// account's, and among themselves oldest first.
std::optional<std::uint64_t> time_;
};
friend bool
@@ -104,6 +130,10 @@ private:
uint256
accountKey(AccountID const& account);
// The Key for tx under the salted account key, id breaking ties
static Key
makeKey(uint256 const& account, STTx const& tx, uint256 const& id);
public:
using const_iterator =
std::map<Key, std::shared_ptr<STTx const>>::const_iterator;

View File

@@ -788,6 +788,13 @@ TxQ::apply(
return {terNO_ACCOUNT, false};
}
// The queue is keyed by SeqProxy, and every time-sequenced transaction
// has SeqProxy sequence(0): two of them would be taken for replacements
// of one another. tryDirectApply already applied it if its fee was high
// enough, so the only thing left is to say it cannot wait.
if (view.rules().enabled(featureJsonTx) && tx->isTimeSequenced())
return {telCAN_NOT_QUEUE, false};
// If the transaction needs a Ticket is that Ticket in the ledger?
SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]);
SeqProxy const txSeqProx = tx->getSeqProxy();
@@ -1967,7 +1974,14 @@ TxQ::tryDirectApply(
std::optional<SeqProxy> txSeqProx;
if (!bypassQueue)
// A time-sequenced transaction has no sequence to match, and is never in
// the queue (TxQ::apply refuses to queue one), so there is no queued
// entry to replace either. Unlike a manifest it still has to pay the
// open ledger fee: it can be spammed like any signed transaction.
bool const timeSequenced =
view.rules().enabled(featureJsonTx) && tx->isTimeSequenced();
if (!bypassQueue && !timeSequenced)
{
SeqProxy const acctSeqProx =
SeqProxy::sequence((*sleAccount)[sfSequence]);

View File

@@ -67,6 +67,16 @@ Change::preflight(PreflightContext const& ctx)
return temBAD_SIGNATURE;
}
// Pseudo-transactions skip preflight1, where every other transaction
// type has these gated on featureJsonTx. They carry no signature for a
// delta to reconstruct, and a node on an older build cannot parse either
// field, so a validator proposing one would split the network.
if (ctx.tx.isFieldPresent(sfTime) || ctx.tx.isFieldPresent(sfJsonTxDelta))
{
JLOG(ctx.j.warn()) << "Change: JsonTx fields on a pseudo-transaction";
return temMALFORMED;
}
if (ctx.tx.getFieldU32(sfSequence) != 0 ||
ctx.tx.isFieldPresent(sfPreviousTxnID))
{

View File

@@ -62,6 +62,14 @@ Cron::preflight(PreflightContext const& ctx)
return temBAD_SIGNATURE;
}
// Cron skips preflight1, where every other transaction type has these
// gated on featureJsonTx; same reasoning as Change::preflight.
if (ctx.tx.isFieldPresent(sfTime) || ctx.tx.isFieldPresent(sfJsonTxDelta))
{
JLOG(ctx.j.warn()) << "Cron: JsonTx fields on a pseudo-transaction";
return temMALFORMED;
}
if (ctx.tx.getFieldU32(sfSequence) != 0 ||
ctx.tx.isFieldPresent(sfPreviousTxnID))
{

View File

@@ -57,6 +57,15 @@ DeleteAccount::preflight(PreflightContext const& ctx)
// An account cannot be deleted and give itself the resulting XRP.
return temDST_IS_SRC;
// Deleting an account also deletes its sfLastTxnTime, which is the only
// thing standing between a time-sequenced transaction and a replay. For
// earlier transactions preclaim waits out their window; this one is
// necessarily still inside its own, so once the account is re-created it
// could be applied again. A Sequence or Ticket is not lost that way: a
// re-created account starts its Sequence at the current ledger index.
if (ctx.tx.isTimeSequenced())
return temBAD_SEQUENCE;
if (auto const err = credentials::checkFields(ctx); !isTesSuccess(err))
return err;
@@ -321,6 +330,24 @@ DeleteAccount::preclaim(PreclaimContext const& ctx)
if ((*sleAccount)[sfSequence] + seqDelta > ctx.view.seq())
return tecTOO_SOON;
// The same protection for sfTime. A re-created account has no
// sfLastTxnTime, so any time-sequenced transaction whose Time is still
// inside the validity window could be applied to it a second time. Every
// one this account has applied had a Time at most sfLastTxnTime, so once
// that is older than txTimeMaxAgeMs none of them can ever apply again.
// (A Time on a transaction sequenced by a Sequence or a Ticket is never
// recorded and needs no such care: the Sequence restarts and the Tickets
// are gone.)
if (auto const last = (*sleAccount)[~sfLastTxnTime])
{
std::uint64_t const close =
static_cast<std::uint64_t>(
ctx.view.parentCloseTime().time_since_epoch().count()) *
1000;
if (close <= *last || close - *last <= txTimeMaxAgeMs)
return tecTOO_SOON;
}
// do not allow the account to be removed if there are hooks installed or
// one or more hook states when these fields are completely empty the field
// is made absent so this test is sufficient these fields cannot be

View File

@@ -262,9 +262,12 @@ NFTokenMint::doApply()
{
std::uint32_t const acctSeq = root->at(sfSequence);
// A time-sequenced transaction, like a Ticket, leaves the
// Sequence untouched.
root->at(sfFirstNFTokenSequence) =
ctx_.tx.isFieldPresent(sfIssuer) ||
ctx_.tx.getSeqProxy().isTicket()
ctx_.tx.getSeqProxy().isTicket() ||
ctx_.tx.isTimeSequenced()
? acctSeq
: acctSeq - 1;
}

View File

@@ -125,14 +125,22 @@ PermissionedDomainSet::doApply()
if (balance < reserve)
return tecINSUFFICIENT_RESERVE;
Keylet const pdKeylet = keylet::permissionedDomain(
account_, ctx_.tx.getFieldU32(sfSequence));
// The raw Sequence is 0 for a transaction that uses a Ticket, so
// before fix20260929 every ticketed domain an account created was
// keyed (account, 0): the second collided with the first, and an
// insert over an existing key is a LogicError when the ledger is
// built. The SeqProxy value is the Ticket number for those, which is
// what every other sequence-keyed object already uses.
std::uint32_t const seq = ctx_.view().rules().enabled(fix20260929)
? ctx_.tx.getSeqProxy().value()
: ctx_.tx.getFieldU32(sfSequence);
Keylet const pdKeylet = keylet::permissionedDomain(account_, seq);
auto slePd = std::make_shared<SLE>(pdKeylet);
if (!slePd)
return tefINTERNAL; // LCOV_EXCL_LINE
slePd->setAccountID(sfOwner, account_);
slePd->setFieldU32(sfSequence, ctx_.tx.getFieldU32(sfSequence));
slePd->setFieldU32(sfSequence, seq);
slePd->peekFieldArray(sfAcceptedCredentials) = std::move(sortedLE);
auto const page = view().dirInsert(
keylet::ownerDir(account_), pdKeylet, describeOwnerDir(account_));

View File

@@ -37,6 +37,7 @@
#include <xrpl/json/to_string.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAccount.h>
#include <xrpl/protocol/UintTypes.h>
@@ -102,6 +103,64 @@ preflight1(PreflightContext const& ctx)
return temMALFORMED;
}
// sfTime and sfJsonTxDelta are common fields, so every transaction type
// can carry them the moment this binary ships. A node on an older build
// cannot parse them, so accepting one into a ledger before the amendment
// activates would split consensus.
if (ctx.tx.isFieldPresent(sfTime) || ctx.tx.isFieldPresent(sfJsonTxDelta))
{
if (!ctx.rules.enabled(featureJsonTx))
return temDISABLED;
// unsanitize_jsontx refuses anything larger, so a bigger delta is
// only ever ledger weight that can never verify
if (ctx.tx.isFieldPresent(sfJsonTxDelta) &&
ctx.tx.getFieldVL(sfJsonTxDelta).size() > jsontx_max_diff)
return temMALFORMED;
// Emitted transactions carry their own replay protection
// (sfEmitDetails) and never a signature, so neither field has a
// meaning on one.
if (ctx.tx.isFieldPresent(sfEmitDetails))
return temMALFORMED;
}
if (auto const time = ctx.tx[~sfTime])
{
// Past this sfTime has no preimage spelling, and nothing in the
// validity window is anywhere near it; refusing it here keeps an
// absurd Time from being held and retried as terPRE_SEQ.
if (*time > jsontx_max_time)
return temMALFORMED;
// These already pin Sequence to 0 for reasons of their own - a first
// Import creates its account, a manifest is derived from the
// manifest alone - so an sfTime would silently reinterpret them as
// time-sequenced.
if (ctx.tx.getTxnType() == ttIMPORT ||
ctx.tx.getTxnType() == ttMANIFEST_SET)
return temMALFORMED;
}
// MPTokenIssuanceCreate and PermissionedDomainSet key the object they
// create by the raw sequence value, (sequence, account). Every other
// creator goes through seqID(), which falls back to the transaction id
// when the SeqProxy is sequence(0) - an emitted or a time-sequenced
// transaction - but these two ids are fixed formats with no room for one.
// Two such transactions from one account would name the same object,
// and inserting over an existing key is a LogicError on every node that
// builds the ledger. So they need a real Sequence or a Ticket.
//
// Time-sequenced transactions are new with featureJsonTx and refused
// unconditionally. Refusing the emitted case changes existing behaviour,
// so it waits for fix20260929. (Neither feature is supported yet, so
// neither case is reachable on a live network today.)
if ((ctx.tx.getTxnType() == ttMPTOKEN_ISSUANCE_CREATE ||
ctx.tx.getTxnType() == ttPERMISSIONED_DOMAIN_SET) &&
ctx.tx.getSeqProxy() == SeqProxy::sequence(0) &&
(ctx.tx.isTimeSequenced() || ctx.rules.enabled(fix20260929)))
return temBAD_SEQUENCE;
auto const ret = preflight0(ctx);
if (!isTesSuccess(ret))
return ret;
@@ -619,6 +678,12 @@ Transactor::checkSeqProxy(
if (tx.isFieldPresent(sfFirstLedgerSequence))
return tefINTERNAL;
// Replay protection comes from sfTime against sfLastTxnTime, which
// checkPriorTxAndLastLedger enforces; the account Sequence is neither
// checked here nor consumed.
if (view.rules().enabled(featureJsonTx) && tx.isTimeSequenced())
return tesSUCCESS;
if (t_seqProx.isSeq())
{
if (tx.isFieldPresent(sfTicketSequence) &&
@@ -701,6 +766,46 @@ Transactor::checkPriorTxAndLastLedger(PreclaimContext const& ctx)
(ctx.view.seq() > ctx.tx.getFieldU32(sfLastLedgerSequence)))
return tefMAX_LEDGER;
// sfTime stands in for LastLedgerSequence on every transaction carrying
// it and, on a time-sequenced one, for the account Sequence too.
if (auto const time = ctx.tx[~sfTime])
{
// milliseconds since the ripple epoch, like sfTime
std::uint64_t const close =
static_cast<std::uint64_t>(
ctx.view.parentCloseTime().time_since_epoch().count()) *
1000;
// Expired: from here on this transaction can never apply, exactly as
// for a LastLedgerSequence in the past. Written without adding to
// *time, which arrives off the wire and could overflow.
if (close > *time && close - *time > txTimeMaxAgeMs)
return tefMAX_LEDGER;
// Not valid yet. Retriable, as for a future sequence: a client clock
// a little ahead of the network's is the common case.
if (*time > close && *time - close > txTimeMaxFutureMs)
return terPRE_SEQ;
// Replay, for a time-sequenced transaction only. Strictly greater, so
// no two of them share a Time, and the Time of every one ever applied
// is at most sfLastTxnTime. With the window above, each is applied at
// most once.
//
// A Sequence or a Ticket is replay protection already, so a
// transaction sequenced by one is neither checked here nor recorded
// in consumeSeqProxy: its Time is only a validity window. Were it
// checked, it would have to apply in Time order among the account's
// time-sequenced transactions as well as in Sequence order, and no
// CanonicalTXSet order satisfies both for every set the open ledger
// can accept, so consensus would drop transactions the open ledger
// took.
if (ctx.tx.isTimeSequenced() && sle &&
sle->isFieldPresent(sfLastTxnTime) &&
*time <= sle->getFieldU64(sfLastTxnTime))
return tefPAST_SEQ;
}
if (ctx.view.txExists(ctx.tx.getTransactionID()))
return tefALREADY;
@@ -764,6 +869,18 @@ Transactor::consumeSeqProxy(SLE::pointer const& sleAccount)
ctx_.tx.getTxnType() == ttMANIFEST_SET)
return tesSUCCESS;
// A time-sequenced transaction records its Time, which is what
// checkPriorTxAndLastLedger's replay check reads; nothing else does (see
// there). This runs in apply() and again in reset(), so a tec result is
// covered too. It has Sequence 0 and no Ticket, so falling through would
// write Sequence = 0 + 1 and make every sequence this account ever used
// replayable.
if (ctx_.tx.isTimeSequenced())
{
sleAccount->setFieldU64(sfLastTxnTime, ctx_.tx.getFieldU64(sfTime));
return tesSUCCESS;
}
SeqProxy const seqProx = ctx_.tx.getSeqProxy();
if (seqProx.isSeq())
{

View File

@@ -321,6 +321,12 @@ seqID(C const& ctx_)
ctx_.tx.isFieldPresent(sfEmitDetails))
return ctx_.tx.getTransactionID();
// Every time-sequenced transaction has Sequence 0, so its SeqProxy
// cannot tell one object from the next. Its transaction id can: sfTime
// is strictly increasing per account, so no two ever share an id.
if (ctx_.view().rules().enabled(featureJsonTx) && ctx_.tx.isTimeSequenced())
return ctx_.tx.getTransactionID();
return ctx_.tx.getSeqProxy().value();
}

View File

@@ -28,6 +28,7 @@
#include <xrpld/app/tx/detail/SetManifest.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/JSONTxSignatures.h>
namespace ripple {
@@ -50,6 +51,62 @@ checkValidity(
auto const id = tx.getTransactionID();
auto const flags = router.getFlags(id);
// A delta decides which signature check applies, so it is examined before
// anything else - including the emitted and manifest branches below, which
// would otherwise accept a delta as inert ballast and give anyone who
// relays such a transaction a free way to mint new transaction ids for it.
if (tx.isFieldPresent(sfJsonTxDelta))
{
// Answered without touching the router. Every cached flag must be a
// function of the transaction alone, because nodes that disagree on
// one disagree on whether a transaction in the agreed set applies.
// Were this cached, a JsonTx relayed across the activation boundary -
// PeerImp checks against the validated ledger's rules, consensus
// against the open ledger's - would be SIGBAD on some nodes and
// SIGGOOD on others.
if (!rules.enabled(featureJsonTx))
return {Validity::SigBad, "JsonTx is not enabled."};
// A JsonTx signs a plaintext preimage, so STTx::checkSign -- a
// check against the binary signing hash -- can never succeed for it,
// and STTx::checkSign refuses any transaction carrying a delta. The
// delta cannot be stripped to downgrade to a binary check either:
// that leaves a TxnSignature over text the binary hash does not match.
//
// This proves only that the key signed the preimage. Tying that key to
// sfAccount is still Transactor::checkSingleSign's job, exactly as for
// a binary-signed transaction.
//
// jsontx_verify canonicalizes twice and then verifies ed25519, and
// checkValidity runs on every relay, so honour the cache. That is safe
// only because nothing but this branch (and forceValidity, from
// trusted sources) ever sets SIGGOOD or SIGBAD on an id carrying a
// delta: the binary path below is unreachable for one.
if (flags & SF_SIGBAD)
return {Validity::SigBad, "Transaction has bad signature."};
if (!(flags & SF_SIGGOOD))
{
try
{
(void)jsontx_verify(tx);
}
catch (std::exception const& e)
{
router.setFlags(id, SF_SIGBAD);
return {Validity::SigBad, e.what()};
}
router.setFlags(id, SF_SIGGOOD);
}
std::string reason;
if (!passesLocalChecks(tx, reason))
return {Validity::SigGoodOnly, reason};
return {Validity::Valid, ""};
}
if (rules.enabled(featureHooks) && tx.isFieldPresent(sfEmitDetails))
{
// emitted transactions do not contain signatures

View File

@@ -262,7 +262,8 @@ TxConsequences::TxConsequences(STTx const& tx)
: beast::zero)
, potentialSpend_(beast::zero)
, seqProx_(tx.getSeqProxy())
, sequencesConsumed_(tx.getSeqProxy().isSeq() ? 1 : 0)
, sequencesConsumed_(
tx.getSeqProxy().isSeq() && !tx.isTimeSequenced() ? 1 : 0)
{
}

View File

@@ -30,9 +30,15 @@
#include <xrpld/rpc/detail/RPCHelpers.h>
#include <xrpld/rpc/detail/TransactionSign.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/json/json_reader.h>
#include <xrpl/json/json_writer.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/RPCErr.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/resource/Fees.h>
namespace ripple {
@@ -88,9 +94,15 @@ doInject(RPC::JsonContext& context)
}
// {
// tx_blob: <string> XOR tx_json: <object>,
// secret: <secret>
// tx_blob: <string>
// OR tx: <json text> together with sig: <hex>
// OR manifest: <hex>
// OR tx_json: <object> together with secret: <secret> (deprecated)
// }
//
// For tx + sig (featureJsonTx), `tx` is the exact JSON text the signer read
// and `sig` is their ed25519 signature over jsontx_signing_data(tx): the
// four bytes jsontx_sign_prefix followed by that text.
Json::Value
doSubmit(RPC::JsonContext& context)
{
@@ -98,17 +110,43 @@ doSubmit(RPC::JsonContext& context)
context.loadType = Resource::feeMediumBurdenRPC;
auto const view = context.app.openLedger().current();
bool const isJsonTx =
context.params.isMember(jss::tx) && context.params.isMember(jss::sig);
bool const hasManifest = context.params.isMember(jss::manifest);
// Half a JsonTx would otherwise fall through to the legacy signing path
// and fail there with an unrelated complaint about tx_json.
if (context.params.isMember(jss::tx) != context.params.isMember(jss::sig))
return RPC::make_param_error(
"JsonTx submission needs both `tx` and `sig`");
bool const hasTxBlob = context.params.isMember(jss::tx_blob);
if (hasManifest && hasTxBlob)
{
// Both of these carry authority that only their amendment teaches the
// network to honour, so without the amendment the submitter is told their
// transaction is unsigned, which reads as their mistake. It isn't -- the
// feature is not live yet -- so say so before touching the payload at all.
if (isJsonTx && !view->rules().enabled(featureJsonTx))
return RPC::make_error(
rpcINVALID_PARAMS,
"Specify exactly one of either `tx_blob` or `manifest`");
}
else if (!hasTxBlob && !hasManifest)
rpcNOT_ENABLED,
"The JsonTx amendment is not enabled on this network. "
"Plaintext-JSON submission will work once it activates; nothing "
"is wrong with this request.");
if (hasManifest && !view->rules().enabled(featureOnChainManifests))
return RPC::make_error(
rpcNOT_ENABLED,
"The OnChainManifests amendment is not enabled on this "
"network. Manifest submission will work once it activates; "
"nothing is wrong with this request.");
int const count =
(hasTxBlob ? 1 : 0) + (isJsonTx ? 1 : 0) + (hasManifest ? 1 : 0);
if (!count)
{
// legacy signing code
auto const failType = getFailHard(context);
if (context.role != Role::ADMIN && !context.app.config().canSign())
@@ -132,30 +170,22 @@ doSubmit(RPC::JsonContext& context)
return ret;
}
else if (count != 1)
{
return RPC::make_error(
rpcINVALID_PARAMS,
"Specify exactly one of `tx_blob`, `manifest`, or `tx` together "
"with `sig`");
}
// execution to here means exactly one of isJsonTx, hasManifest or
// hasTxBlob is true
std::string txBlob =
hasTxBlob ? context.params[jss::tx_blob].asString() : "";
if (hasManifest)
{
// OnChainManifests amendment accepts a manifest submission here; turn
// it into the transaction that carries it and drop through to normal
// tx_blob processing below.
auto const view = context.app.openLedger().current();
// The transaction built below carries no account signature; its
// authority is the manifest's own master and ephemeral signatures,
// which checkValidity() only honours once the amendment is active.
// Without this the submitter is told their transaction is unsigned,
// which reads as their mistake. It isn't -- the feature is not live
// yet -- so say so before touching the manifest at all.
if (!view->rules().enabled(featureOnChainManifests))
return RPC::make_error(
rpcNOT_ENABLED,
"The OnChainManifests amendment is not enabled on this "
"network. Manifest submission will work once it activates; "
"nothing is wrong with this request.");
auto const raw = strUnHex(context.params[jss::manifest].asString());
if (!raw || raw->empty())
return rpcError(rpcINVALID_PARAMS);
@@ -175,18 +205,103 @@ doSubmit(RPC::JsonContext& context)
txBlob = *hex;
}
auto ret = strUnHex(txBlob);
std::optional<Blob> ret;
if (!ret || !ret->size())
return rpcError(rpcINVALID_PARAMS);
if (!isJsonTx)
{
ret = strUnHex(txBlob);
SerialIter sitTrans(makeSlice(*ret));
if (!ret || ret->empty())
return rpcError(rpcINVALID_PARAMS);
}
std::shared_ptr<STTx const> stTx;
try
{
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
if (!isJsonTx)
{
SerialIter sitTrans(makeSlice(*ret));
stTx = std::make_shared<STTx const>(std::ref(sitTrans));
}
else
{
// the preimage is the signer's exact bytes, so it arrives as a
// string; an object would already have been re-serialized by
// someone other than the signer
if (!context.params[jss::tx].isString() ||
!context.params[jss::sig].isString())
throw std::runtime_error(
"JsonTx: tx and sig must both be strings");
std::string const raw = context.params[jss::tx].asString();
auto const [san, diff] = sanitize_jsontx(raw);
auto const sig = strUnHex(context.params[jss::sig].asString());
if (!sig || sig->empty())
throw std::runtime_error("JsonTx: bad signature");
Json::Value jv;
if (Json::Reader r; !r.parse(san, jv))
throw std::runtime_error("JsonTx: unparsable canonical form");
// The preimage carries the key but not the signature over itself,
// nor the delta, which is derived from it.
for (auto const& n :
{sfTxnSignature.fieldName,
sfSigners.fieldName,
sfJsonTxDelta.fieldName})
if (jv.isMember(n))
throw std::runtime_error(
"JsonTx: " + n + " must not appear in tx");
if (!jv.isMember(sfSigningPubKey.fieldName))
throw std::runtime_error("JsonTx: tx must carry SigningPubKey");
// The canonical form already wrote an omitted Sequence as 0 if
// the document has a Time or a TicketSequence, so an absent one
// here means it has neither. Say so, rather than let the template
// check report a missing field. And there is deliberately no
// autofill: the account's next sequence is ledger state, and the
// signer's text has to determine the transaction by itself.
if (!jv.isMember(sfSequence.fieldName))
throw std::runtime_error(
"JsonTx: no Sequence: add a Time, a TicketSequence, or a "
"Sequence");
// Hand the parser the u64 rather than teaching STUInt64 a second
// spelling; the ISO form only ever exists in the preimage.
std::optional<std::uint64_t> ms;
if (jv.isMember(sfTime.fieldName))
{
ms = jsontx_iso(jv[sfTime.fieldName].asString());
jv.removeMember(sfTime.fieldName);
}
STParsedJSONObject parsed("tx_json", jv);
if (!parsed.object)
throw std::runtime_error(
parsed.error[jss::error_message].asString());
if (ms)
parsed.object->setFieldU64(sfTime, *ms);
parsed.object->setFieldVL(sfTxnSignature, *sig);
// The delta rides along in the transaction. Without it a relaying
// node has nothing to reconstruct the preimage from, the binary
// TxnSignature check fails there, and the transaction never
// propagates past this node.
parsed.object->setFieldVL(sfJsonTxDelta, makeSlice(diff));
stTx = std::make_shared<STTx const>(std::move(*parsed.object));
// Round-trip the binary codec and run the check a relaying node
// will run, so this path cannot accept anything the network would
// later reject -- and so the caller gets the real reason rather
// than a bare "fails local checks" from checkValidity below.
Serializer ser;
stTx->add(ser);
SerialIter si(ser.slice());
STTx const rt{si};
if (jsontx_verify(rt) != raw)
throw std::runtime_error("JsonTx: does not round-trip");
}
}
catch (std::exception& e)
{