Compare commits

...
31 Commits
Author SHA1 Message Date
Richard Holland 4d00580a52 failing tests 2026-10-20 14:06:42 +11:00
Richard Holland 5db97be877 test clock fix 2026-10-16 16:59:35 +11:00
Richard Holland d70fd6fa72 fix stub 2026-10-16 12:04:02 +11:00
Richard Holland f9747d1633 credit note init 2026-10-15 12:44:19 +11:00
Richard Holland 0074cf9e95 Merge dev into jsontx; move sfJsonTxDelta to VL 95 (96 taken by sfAppLoader) 2026-10-15 10:38:24 +11:00
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
37 changed files with 6163 additions and 44 deletions

No files matched your search

+6
View File
@@ -108,6 +108,9 @@
#define sfMPTAmount ((3U << 16U) + 26U)
#define sfIssuerNode ((3U << 16U) + 27U)
#define sfSubjectNode ((3U << 16U) + 28U)
#define sfLastNoteTime ((3U << 16U) + 94U)
#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)
@@ -227,6 +230,9 @@
#define sfProvider ((7U << 16U) + 30U)
#define sfMPTokenMetadata ((7U << 16U) + 31U)
#define sfCredentialType ((7U << 16U) + 32U)
#define sfNote ((7U << 16U) + 93U)
#define sfNoteSignature ((7U << 16U) + 94U)
#define sfJsonTxDelta ((7U << 16U) + 95U)
#define sfAppLoader ((7U << 16U) + 96U)
#define sfHookName ((7U << 16U) + 97U)
#define sfRemarkValue ((7U << 16U) + 98U)
+1
View File
@@ -62,6 +62,7 @@
#define ttPERMISSIONED_DOMAIN_SET 71
#define ttPERMISSIONED_DOMAIN_DELETE 72
#define ttENTROPY 89
#define ttNOTE_CLAIM 90
#define ttMANIFEST_SET 91
#define ttCRON 92
#define ttCRON_SET 93
+92
View File
@@ -0,0 +1,92 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
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_CREDITNOTE_H_INCLUDED
#define RIPPLE_PROTOCOL_CREDITNOTE_H_INCLUDED
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <cstdint>
#include <string_view>
namespace ripple {
//------------------------------------------------------------------------------
// Credit notes (featureCreditNote)
//
// A note is a bearer instrument the issuer signs blind, typically offline:
// "whoever holds this may take Amount from my account until Expiration".
// It is a JsonTx document in the sense of featureJsonTx - the same character
// set, number rules and signing prefix as a plaintext-signed transaction - so
// anything that can sign a JsonTx preimage can sign a note, and the bytes the
// issuer read are the bytes the ledger checks. There is no canonical form and
// no delta: NoteClaim carries the note verbatim.
//
// The document is exactly one object with exactly these five members, in any
// order, spelled exactly so (no case folding - what you see is what you get):
//
// "NetworkID" bare integer the network the note redeems on
// "Account" r-address the issuer; who pays
// "Amount" bare integer drops of the native currency, or
// object {"currency","issuer","value"}, all strings,
// an issued currency as a Payment spells it
// "IssueTime" ISO 8601 Date().toISOString(), as sfTime renders
// "Expiration" ISO 8601 after IssueTime, at most creditNoteMaxLifeMs
// later
//
// TransactionType is not a member, and IssueTime is not a transaction field,
// so no note is a valid transaction preimage and no transaction preimage is
// a valid note: the shared signing prefix needs no further separation.
//
// Replay protection is the issuer's AccountRoot sfLastNoteTime: a note can
// be redeemed only while its IssueTime is strictly greater, and redeeming
// records its IssueTime there. Each note redeems at most once, and redeeming
// one retires every unredeemed note the issuer signed before it.
//
// CONSENSUS SURFACE. Everything in parseCreditNote decides whether a note is
// valid, and is a consensus rule once featureCreditNote activates.
//------------------------------------------------------------------------------
struct CreditNote
{
std::uint32_t networkID = 0;
AccountID account;
STAmount amount;
std::uint64_t issueTime = 0; // milliseconds since the ripple epoch
std::uint64_t expiration = 0; // milliseconds since the ripple epoch
};
/** Parses and validates the exact bytes of a note.
Accepts only the document described above, after jsontx_strict has
accepted it as a JsonTx document. Checks the shape, the account and
currency spellings, that Amount is positive and legal, and that the
validity window is well formed and no longer than creditNoteMaxLifeMs.
Everything that needs a ledger - the signing key's authority, the window
against the close time, sfLastNoteTime, funds and trust lines - is left to
the NoteClaim transactor. Throws std::runtime_error with the reason.
*/
CreditNote
parseCreditNote(std::string_view raw);
} // namespace ripple
#endif
+182
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
+29
View File
@@ -173,6 +173,35 @@ 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;
/** Credit notes (featureCreditNote).
A note is a JsonTx document the issuer signs blind, possibly offline,
that entitles whoever bears it to Amount from the issuer's account
between its IssueTime and its Expiration. The cap on its text keeps a
NoteClaim small and the parser's work bounded; the cap on its life
bounds both how long a lost note stays live and how far an issuer's
sfLastNoteTime can be pushed ahead by one of their own notes.
Both are consensus rules once featureCreditNote activates.
*/
std::size_t constexpr maxCreditNoteBytes = 512;
std::uint64_t constexpr creditNoteMaxLifeMs = 3'600'000;
} // namespace ripple
#endif
+12
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;
@@ -34,6 +34,8 @@
// 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_FEATURE(CreditNote, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(RNG, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FEATURE(PWALoader, Supported::yes, VoteBehavior::DefaultNo)
XRPL_FIX (20261005, Supported::yes, VoteBehavior::DefaultYes)
@@ -300,6 +300,8 @@ LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
{sfAMMID, soeOPTIONAL},
{sfAppLoaderSequence, soeOPTIONAL},
{sfManifestID, soeOPTIONAL},
{sfLastTxnTime, soeOPTIONAL},
{sfLastNoteTime, soeOPTIONAL},
}))
/** A ledger object which contains a list of object identifiers.
@@ -156,6 +156,10 @@ 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)
// featureCreditNote: IssueTime of the last note this account redeemed
TYPED_SFIELD(sfLastNoteTime, UINT64, 94)
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)
@@ -300,6 +304,12 @@ TYPED_SFIELD(sfAssetClass, VL, 29)
TYPED_SFIELD(sfProvider, VL, 30)
TYPED_SFIELD(sfMPTokenMetadata, VL, 31)
TYPED_SFIELD(sfCredentialType, VL, 32)
// featureCreditNote: the note text and the issuer's signature over it. Both
// are covered by the bearer's TxnSignature (neither is notSigning) so a
// redemption cannot be re-pointed at a different note in flight.
TYPED_SFIELD(sfNote, VL, 93)
TYPED_SFIELD(sfNoteSignature, VL, 94)
TYPED_SFIELD(sfJsonTxDelta, VL, 95, SField::sMD_Default, SField::notSigning)
TYPED_SFIELD(sfAppLoader, VL, 96)
TYPED_SFIELD(sfHookName, VL, 97)
TYPED_SFIELD(sfRemarkValue, VL, 98)
@@ -517,6 +517,23 @@ TRANSACTION(ttENTROPY, 89, Entropy, ({
{sfRandomData, soeOPTIONAL},
}))
/** featureCreditNote: redeem a credit note.
The bearer (sfAccount) submits the exact bytes the issuer signed (sfNote,
a JsonTx document naming NetworkID, Account, Amount, IssueTime and
Expiration), the issuer's ed25519 signature over
jsontx_sign_prefix || sfNote (sfNoteSignature) and the key that made it
(sfPublicKey, the issuer's master or regular key). Amount moves from the
note's Account to the bearer; the note's IssueTime is recorded in the
issuer's AccountRoot as sfLastNoteTime, and only notes issued after it
can still be redeemed.
*/
TRANSACTION(ttNOTE_CLAIM, 90, NoteClaim, ({
{sfNote, soeREQUIRED},
{sfNoteSignature, soeREQUIRED},
{sfPublicKey, soeREQUIRED},
}))
/* sfAccount is supplied by TxFormats::commonFields; listing it here as well
makes the SOTemplate reject the format at construction. */
TRANSACTION(ttMANIFEST_SET, 91, SetManifest, ({
+1
View File
@@ -641,6 +641,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
+363
View File
@@ -0,0 +1,363 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
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/protocol/CreditNote.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/UintTypes.h>
#include <algorithm>
#include <array>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>
namespace ripple {
namespace {
[[noreturn]] void
fail(std::string const& why)
{
Throw<std::runtime_error>("creditnote: " + why);
}
constexpr bool
digit(char c)
{
return c >= '0' && c <= '9';
}
constexpr bool
space(char c)
{
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
// A note's values are strings, bare integers, and - for an issued Amount -
// one object of strings. Nothing else.
struct Val
{
enum class Kind : std::uint8_t { string, number, object };
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<Val> items; // object member values
};
// A reader for the note language only. jsontx_strict has already accepted
// the document - one object, printable ASCII, no escapes, no duplicates,
// bounded integers - so this reader's job is shape, not sanitation; it still
// refuses anything outside that language rather than assume it.
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 note");
return s_[p_];
}
void
expect(char c, char const* what)
{
if (peek() != c)
fail(what);
++p_;
}
std::string
string()
{
expect('"', "expected a string");
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 == '\\' || c < 0x20 || c > 0x7E)
fail("strings are printable ASCII without escapes");
++p_;
}
std::string r(s_.substr(b, p_ - b));
++p_; // closing quote
return r;
}
std::string
number()
{
std::size_t const b = p_;
if (peek() == '-')
++p_;
if (!digit(peek()))
fail("malformed number");
if (s_[p_] == '0')
{
++p_;
if (p_ < s_.size() && digit(s_[p_]))
fail("malformed number");
}
else
{
while (p_ < s_.size() && digit(s_[p_]))
++p_;
}
if (p_ < s_.size() && (s_[p_] == '.' || s_[p_] == 'e' || s_[p_] == 'E'))
fail("numbers are integers");
return std::string(s_.substr(b, p_ - b));
}
Val
value(int depth)
{
skip();
char const c = peek();
if (c == '{')
return object(depth + 1);
Val v;
if (c == '"')
{
v.kind = Val::Kind::string;
v.text = string();
return v;
}
if (c == '-' || digit(c))
{
v.kind = Val::Kind::number;
v.text = number();
return v;
}
fail("unexpected token");
}
Val
object(int depth)
{
// the note, and at most one object inside it (an issued Amount)
if (depth > 2)
fail("nested too deeply");
Val v;
v.kind = Val::Kind::object;
expect('{', "expected an object");
skip();
if (peek() == '}')
{
++p_;
return v;
}
for (;;)
{
skip();
auto key = string();
if (std::find(v.keys.begin(), v.keys.end(), key) != v.keys.end())
fail("duplicate member " + key);
skip();
expect(':', "expected ':'");
v.items.push_back(value(depth));
v.keys.push_back(std::move(key));
skip();
char const c = peek();
if (c == ',')
{
++p_;
continue;
}
if (c == '}')
{
++p_;
return v;
}
fail("expected ',' or '}'");
}
}
public:
explicit Reader(std::string_view s) : s_(s)
{
}
Val
parse()
{
skip();
if (p_ >= s_.size() || s_[p_] != '{')
fail("a note is a JSON object");
auto root = object(1);
skip();
if (p_ != s_.size())
fail("trailing data after note");
return root;
}
};
Val const&
member(Val const& obj, std::string_view key)
{
for (std::size_t i = 0; i < obj.keys.size(); ++i)
if (obj.keys[i] == key)
return obj.items[i];
fail("missing " + std::string(key));
}
std::string const&
str(Val const& obj, std::string_view key)
{
auto const& v = member(obj, key);
if (v.kind != Val::Kind::string)
fail(std::string(key) + " must be a string");
return v.text;
}
// A non-negative bare integer, already known to be well formed and at most
// jsontx_max_int, as a number.
std::uint64_t
unsignedValue(Val const& v, std::string_view key)
{
if (v.kind != Val::Kind::number)
fail(std::string(key) + " must be a bare integer");
if (v.text.empty() || v.text[0] == '-')
fail(std::string(key) + " must not be negative");
std::uint64_t n = 0;
for (char const c : v.text)
n = n * 10 + static_cast<std::uint64_t>(c - '0');
return n;
}
} // namespace
//------------------------------------------------------------------------------
CreditNote
parseCreditNote(std::string_view raw)
{
if (raw.size() > maxCreditNoteBytes)
fail("note too large");
// The note is a JsonTx document first: same character set, integer
// rules and bounds as a plaintext-signed transaction preimage.
jsontx_strict(raw);
auto const root = Reader(raw).parse();
static constexpr std::array<std::string_view, 5> allowed{
"NetworkID", "Account", "Amount", "IssueTime", "Expiration"};
// Exactly the five, each exactly once (the reader refused duplicates).
// Anything else - including a TransactionType - is not a note.
if (root.keys.size() != allowed.size())
fail(
"a note has exactly NetworkID, Account, Amount, IssueTime and "
"Expiration");
for (auto const& k : root.keys)
if (std::find(allowed.begin(), allowed.end(), k) == allowed.end())
fail("unknown member " + k);
CreditNote note;
{
auto const n = unsignedValue(member(root, "NetworkID"), "NetworkID");
if (n > std::numeric_limits<std::uint32_t>::max())
fail("NetworkID out of range");
note.networkID = static_cast<std::uint32_t>(n);
}
{
auto const id = parseBase58<AccountID>(str(root, "Account"));
if (!id || *id == noAccount() || isXRP(*id))
fail("Account is not an r-address");
note.account = *id;
}
{
auto const& a = member(root, "Amount");
if (a.kind == Val::Kind::number)
{
// drops of the native currency; at most jsontx_max_int, which
// is below the total supply
auto const drops = unsignedValue(a, "Amount");
note.amount = STAmount{XRPAmount{static_cast<std::int64_t>(drops)}};
}
else if (a.kind == Val::Kind::object)
{
if (a.keys.size() != 3)
fail(
"an issued Amount has exactly currency, issuer and "
"value");
Currency currency;
if (!to_currency(currency, str(a, "currency")) || isXRP(currency) ||
isBadCurrency(currency))
fail("bad currency");
auto const issuer = parseBase58<AccountID>(str(a, "issuer"));
if (!issuer || *issuer == noAccount() || isXRP(*issuer))
fail("issuer is not an r-address");
try
{
note.amount =
amountFromString(Issue{currency, *issuer}, str(a, "value"));
}
catch (std::exception const&)
{
fail("bad value");
}
}
else
{
fail("Amount is drops or an issued amount");
}
if (note.amount.signum() <= 0 || !isLegalNet(note.amount))
fail("Amount must be positive");
}
note.issueTime = jsontx_iso(str(root, "IssueTime"));
note.expiration = jsontx_iso(str(root, "Expiration"));
if (note.expiration <= note.issueTime)
fail("Expiration must be after IssueTime");
if (note.expiration - note.issueTime > creditNoteMaxLifeMs)
fail(
"a note lives at most " +
std::to_string(creditNoteMaxLifeMs / 60000) + " minutes");
return note;
}
} // namespace ripple
+992
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
+16
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
+2
View File
@@ -49,6 +49,8 @@ TxFormats::TxFormats()
{sfNetworkID, soeOPTIONAL},
{sfHookParameters, soeOPTIONAL},
{sfHookName, soeOPTIONAL},
{sfTime, soeOPTIONAL},
{sfJsonTxDelta, soeOPTIONAL},
};
#pragma push_macro("UNWRAP")
+449
View File
@@ -0,0 +1,449 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
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 <test/jtx.h>
#include <xrpld/core/Config.h>
#include <xrpl/basics/strHex.h>
#include <xrpl/protocol/CreditNote.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/jss.h>
#include <chrono>
#include <optional>
#include <string>
namespace ripple {
namespace test {
struct CreditNote_test : public beast::unit_test::suite
{
//--------------------------------------------------------------------------
// helpers
// The open ledger's parent close time in milliseconds since the ripple
// epoch: the clock NoteClaim checks the note's window against. A fresh
// Env starts seconds after the epoch, too early to backdate a note from
// (the subtraction would wrap), so a young clock is first moved an hour on.
static std::uint64_t
nowMs(jtx::Env& env)
{
using namespace std::chrono_literals;
if (env.current()->parentCloseTime().time_since_epoch() < 1h)
env.close(1h);
return static_cast<std::uint64_t>(env.current()
->parentCloseTime()
.time_since_epoch()
.count()) *
1000;
}
static std::optional<std::uint64_t>
lastNoteTime(jtx::Env& env, jtx::Account const& a)
{
auto const sle = env.le(a);
if (!sle || !sle->isFieldPresent(sfLastNoteTime))
return std::nullopt;
return sle->getFieldU64(sfLastNoteTime);
}
// The note laid out the way a browser's JSON.stringify would.
static std::string
noteText(
jtx::Env& env,
jtx::Account const& issuer,
std::string const& amount,
std::uint64_t issueTime,
std::uint64_t expiration)
{
return "{\"NetworkID\":" +
std::to_string(env.app().config().NETWORK_ID) + ",\"Account\":\"" +
issuer.human() + "\",\"Amount\":" + amount + ",\"IssueTime\":\"" +
jsontx_iso_str(issueTime) + "\",\"Expiration\":\"" +
jsontx_iso_str(expiration) + "\"}";
}
static std::string
iou(jtx::IOU const& currency, std::string const& value)
{
return "{\"currency\":\"" + to_string(currency.currency) +
"\",\"issuer\":\"" + currency.account.human() + "\",\"value\":\"" +
value + "\"}";
}
static Buffer
noteSign(jtx::Account const& signer, std::string const& text)
{
return sign(
signer.pk(), signer.sk(), makeSlice(jsontx_signing_data(text)));
}
// A NoteClaim from `bearer` carrying `text`, signed by `signer`'s key.
static Json::Value
claim(
jtx::Account const& bearer,
std::string const& text,
jtx::Account const& signer)
{
Json::Value jv;
jv[jss::TransactionType] = "NoteClaim";
jv[jss::Account] = bearer.human();
jv[sfNote.fieldName] = strHex(text);
jv[sfNoteSignature.fieldName] = strHex(noteSign(signer, text));
jv[sfPublicKey.fieldName] = strHex(signer.pk().slice());
return jv;
}
// A fresh ten-minute note issued a minute ago.
static std::string
freshNote(
jtx::Env& env,
jtx::Account const& issuer,
std::string const& amount,
std::int64_t offsetMs = 0)
{
auto const issue = static_cast<std::uint64_t>(
static_cast<std::int64_t>(nowMs(env)) - 60'000 + offsetMs);
return noteText(env, issuer, amount, issue, issue + 600'000);
}
//--------------------------------------------------------------------------
void
testParse()
{
testcase("parse");
using namespace jtx;
Env env{*this, supported_amendments()};
Account const alice{"alice", KeyType::ed25519};
Account const gw{"gw"};
auto const USD = gw["USD"];
auto const ok = [&](std::string const& text) {
try
{
parseCreditNote(text);
return true;
}
catch (std::exception const&)
{
return false;
}
};
std::uint64_t const t = 800'000'000'000ULL;
auto const good = noteText(env, alice, "10000000", t, t + 600'000);
BEAST_EXPECT(ok(good));
{
auto const n = parseCreditNote(good);
BEAST_EXPECT(n.account == alice.id());
BEAST_EXPECT(n.amount == STAmount{XRPAmount{10'000'000}});
BEAST_EXPECT(n.issueTime == t);
BEAST_EXPECT(n.expiration == t + 600'000);
}
// whitespace is the issuer's to choose; the bytes are what is signed
BEAST_EXPECT(
ok(" {\n \"NetworkID\": 0 ,\n\"Account\":\"" + alice.human() +
"\",\"Amount\":1,\"IssueTime\":\"" + jsontx_iso_str(t) +
"\",\"Expiration\":\"" + jsontx_iso_str(t + 1) + "\" }\n"));
// issued amounts
BEAST_EXPECT(ok(noteText(env, alice, iou(USD, "10"), t, t + 600'000)));
BEAST_EXPECT(ok(noteText(env, alice, iou(USD, "0.5"), t, t + 600'000)));
BEAST_EXPECT(!ok(noteText(env, alice, iou(USD, "0"), t, t + 600'000)));
BEAST_EXPECT(!ok(noteText(env, alice, iou(USD, "-1"), t, t + 600'000)));
BEAST_EXPECT(!ok(noteText(
env,
alice,
"{\"currency\":\"USD\",\"issuer\":\"" + gw.human() + "\"}",
t,
t + 600'000)));
BEAST_EXPECT(!ok(noteText(
env,
alice,
"{\"currency\":\"XAH\",\"issuer\":\"" + gw.human() +
"\",\"value\":\"1\"}",
t,
t + 600'000)));
// shape
BEAST_EXPECT(!ok(noteText(env, alice, "0", t, t + 600'000)));
BEAST_EXPECT(!ok(noteText(env, alice, "-5", t, t + 600'000)));
BEAST_EXPECT(!ok(noteText(env, alice, "\"10\"", t, t + 600'000)));
BEAST_EXPECT(!ok(noteText(env, alice, "1.5", t, t + 600'000)));
BEAST_EXPECT(!ok(""));
BEAST_EXPECT(!ok("[]"));
BEAST_EXPECT(!ok(good + " x"));
BEAST_EXPECT(!ok(good.substr(0, good.size() - 1)));
// exactly the five members, spelled exactly so
BEAST_EXPECT(
!ok(good.substr(0, good.size() - 1) + ",\"Memo\":\"hi\"}"));
BEAST_EXPECT(
!ok(good.substr(0, good.size() - 1) +
",\"TransactionType\":\"Payment\"}"));
{
auto bad = good;
bad.replace(bad.find("\"Account\""), 9, "\"account\"");
BEAST_EXPECT(!ok(bad));
}
{
auto bad = good;
bad.replace(bad.find("\"IssueTime\""), 11, "\"Expiration\"");
BEAST_EXPECT(!ok(bad)); // duplicate Expiration
}
// window
BEAST_EXPECT(!ok(noteText(env, alice, "1", t, t)));
BEAST_EXPECT(!ok(noteText(env, alice, "1", t + 1, t)));
BEAST_EXPECT(ok(noteText(env, alice, "1", t, t + creditNoteMaxLifeMs)));
BEAST_EXPECT(
!ok(noteText(env, alice, "1", t, t + creditNoteMaxLifeMs + 1)));
// a time the way sfTime renders, or nothing
BEAST_EXPECT(
!ok("{\"NetworkID\":0,\"Account\":\"" + alice.human() +
"\",\"Amount\":1,\"IssueTime\":" + std::to_string(t) +
",\"Expiration\":\"" + jsontx_iso_str(t + 1) + "\"}"));
// size
{
std::string big = good;
big.insert(1, std::string(maxCreditNoteBytes, ' '));
BEAST_EXPECT(!ok(big));
}
}
void
testDisabled()
{
testcase("disabled");
using namespace jtx;
Env env{*this, supported_amendments() - featureCreditNote};
Account const alice{"alice", KeyType::ed25519};
Account const bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
env(claim(bob, freshNote(env, alice, "10000000"), alice),
ter(temDISABLED));
}
void
testXAH()
{
testcase("native");
using namespace jtx;
Env env{*this, supported_amendments()};
Account const alice{"alice", KeyType::ed25519};
Account const bob{"bob"};
Account const carol{"carol"}; // secp256k1
Account const dave{"dave", KeyType::ed25519}; // never funded
env.fund(XRP(10000), alice, bob, carol);
env.close();
auto const text = freshNote(env, alice, "10000000");
// a well-formed ed25519 signature by a key the issuer does not hold
env(claim(bob, text, dave), ter(tecNO_PERMISSION));
// signature over different bytes
{
auto jv = claim(bob, text, alice);
jv[sfNote.fieldName] = strHex(freshNote(env, alice, "10000001"));
env(jv, ter(temBAD_SIGNATURE));
}
// not ed25519
env(claim(bob, text, carol), ter(temMALFORMED));
// wrong network
{
auto bad = text;
auto const nid = "\"NetworkID\":" +
std::to_string(env.app().config().NETWORK_ID);
bad.replace(bad.find(nid), nid.size(), "\"NetworkID\":9");
env(claim(bob, bad, alice), ter(temMALFORMED));
}
// redeeming your own note
env(claim(alice, text, alice), ter(temREDUNDANT));
// too early, expired
env(claim(bob, freshNote(env, alice, "10000000", 120'000), alice),
ter(terPRE_SEQ));
{
auto const t = nowMs(env) - 700'000;
env(claim(
bob,
noteText(env, alice, "10000000", t, t + 600'000),
alice),
ter(tecEXPIRED));
}
// issuer cannot cover it above their reserve
env(claim(bob, freshNote(env, alice, "10000000000"), alice),
ter(tecUNFUNDED_PAYMENT));
BEAST_EXPECT(!lastNoteTime(env, alice));
// the note redeems, and once
auto const issued = parseCreditNote(text).issueTime;
STAmount const before = env.balance(bob);
STAmount const fee{env.current()->fees().base};
env(claim(bob, text, alice));
env.close();
BEAST_EXPECT(env.balance(bob) == before + XRP(10).value() - fee);
BEAST_EXPECT(lastNoteTime(env, alice) == issued);
env(claim(bob, text, alice), ter(tefPAST_SEQ));
env(claim(carol, text, alice), ter(tefPAST_SEQ));
// a note issued before the redeemed one is retired with it ...
env(claim(
bob,
noteText(env, alice, "1000000", issued - 1, issued + 600'000),
alice),
ter(tefPAST_SEQ));
// ... and one issued after it still redeems
auto const later =
noteText(env, alice, "1000000", issued + 1, issued + 600'000);
env(claim(carol, later, alice));
env.close();
BEAST_EXPECT(lastNoteTime(env, alice) == issued + 1);
}
void
testRegularKey()
{
testcase("regular key");
using namespace jtx;
Env env{*this, supported_amendments()};
Account const alice{"alice"}; // secp256k1 master
Account const alie{"alie", KeyType::ed25519};
Account const bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
// alice's master is secp256k1, which cannot sign a note
env(claim(bob, freshNote(env, alice, "1000000"), alice),
ter(temMALFORMED));
// an ed25519 key she does not hold yet
env(claim(bob, freshNote(env, alice, "1000000"), alie),
ter(tecNO_PERMISSION));
// Close before the regkey: a tec stays in the open ledger and is
// re-applied at close in canonical order, where it could land after
// the regkey and redeem.
env.close();
// as her regular key it signs for her
env(regkey(alice, alie));
env.close();
env(claim(bob, freshNote(env, alice, "1000000"), alie));
env.close();
// and a disabled master no longer does
Account const carol{"carol", KeyType::ed25519};
env.fund(XRP(10000), carol);
env(regkey(carol, alie));
env(fset(carol, asfDisableMaster), sig(carol));
env.close();
env(claim(bob, freshNote(env, carol, "1000000"), carol),
ter(tecNO_PERMISSION));
env(claim(bob, freshNote(env, carol, "1000000", 1), alie));
env.close();
}
void
testIOU()
{
testcase("issued currency");
using namespace jtx;
Env env{*this, supported_amendments()};
Account const alice{"alice", KeyType::ed25519};
Account const bob{"bob"};
Account const gw{"gw", KeyType::ed25519};
auto const USD = gw["USD"];
env.fund(XRP(10000), alice, bob, gw);
env.close();
env(trust(alice, USD(1000)));
env(pay(gw, alice, USD(100)));
env.close();
// bob has no trust line; none is created
env(claim(bob, freshNote(env, alice, iou(USD, "10")), alice),
ter(tecNO_LINE));
env(trust(bob, USD(1000)));
env.close();
// more than alice holds
env(claim(bob, freshNote(env, alice, iou(USD, "101")), alice),
ter(tecUNFUNDED_PAYMENT));
env.close();
// frozen line
env(trust(gw, bob["USD"](0), tfSetFreeze));
env.close();
env(claim(bob, freshNote(env, alice, iou(USD, "10")), alice),
ter(tecFROZEN));
// The rejected claim must close before the unfreeze: otherwise the
// close re-applies both in canonical order and, with the unfreeze
// first, the note redeems after all.
env.close();
env(trust(gw, bob["USD"](0), tfClearFreeze));
env.close();
// transit: bob receives 10, alice pays 10 plus the transfer fee
env(rate(gw, 1.25));
env.close();
env(claim(bob, freshNote(env, alice, iou(USD, "10")), alice));
env.close();
BEAST_EXPECT(env.balance(bob, USD) == USD(10));
BEAST_EXPECT(env.balance(alice, USD) == USD(87.5));
// issue: the gateway's own note, no fee
env(claim(bob, freshNote(env, gw, iou(USD, "5")), gw));
env.close();
BEAST_EXPECT(env.balance(bob, USD) == USD(15));
// redemption: a note cashed by the gateway, no line needed on its side
env(claim(gw, freshNote(env, alice, iou(USD, "7"), 1000), alice));
env.close();
BEAST_EXPECT(env.balance(alice, USD) == USD(80.5));
}
void
run() override
{
testParse();
testDisabled();
testXAH();
testRegularKey();
testIOU();
}
};
BEAST_DEFINE_TESTSUITE(CreditNote, app, ripple);
} // namespace test
} // namespace ripple
File diff suppressed because it is too large. Load diff
+110
View File
@@ -31,6 +31,7 @@
#include <xrpl/hook/Enum.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/PayChan.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Sign.h>
@@ -8437,6 +8438,115 @@ private:
return *hash;
}
// NoteClaim
// | otxn | tsh | claim |
// | B | B | S | bearer
// | B | I | S | note issuer (pays the note)
void
testNoteClaimTSH(FeatureBitset features)
{
using namespace test::jtx;
using namespace std::literals;
testcase("note claim TSH");
features = features | featureCreditNote;
// A fresh ten-minute XAH note from `issuer`, issued a minute ago and
// laid out the way CreditNote_test builds them.
auto const makeNote = [](Env& env, Account const& issuer) {
// a fresh Env's clock is too young to backdate a note from
if (env.current()->parentCloseTime().time_since_epoch() < 1h)
env.close(1h);
auto const nowMs =
static_cast<std::uint64_t>(env.current()
->parentCloseTime()
.time_since_epoch()
.count()) *
1000;
auto const issue = nowMs - 60'000;
return "{\"NetworkID\":" +
std::to_string(env.app().config().NETWORK_ID) +
",\"Account\":\"" + issuer.human() +
"\",\"Amount\":10000000,\"IssueTime\":\"" +
jsontx_iso_str(issue) + "\",\"Expiration\":\"" +
jsontx_iso_str(issue + 600'000) + "\"}";
};
// A NoteClaim from `bearer` redeeming `text`, signed by `issuer`.
auto const claim = [](Account const& bearer,
Account const& issuer,
std::string const& text) {
Json::Value jv;
jv[jss::TransactionType] = "NoteClaim";
jv[jss::Account] = bearer.human();
jv[sfNote.fieldName] = strHex(text);
jv[sfNoteSignature.fieldName] = strHex(sign(
issuer.pk(),
issuer.sk(),
makeSlice(jsontx_signing_data(text))));
jv[sfPublicKey.fieldName] = strHex(issuer.pk().slice());
return jv;
};
// otxn: bearer
// tsh bearer
// w/s: strong
for (bool const testStrong : {true, false})
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337, "10", "1000000", "200000"),
features};
auto const issuer = Account("alice", KeyType::ed25519);
auto const bearer = Account("bob");
env.fund(XRP(1000), issuer, bearer);
env.close();
if (!testStrong)
addWeakTSH(env, bearer);
setTSHHook(env, bearer, testStrong);
env(claim(bearer, issuer, makeNote(env, issuer)),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
testTSHStrongWeak(env, tshSTRONG, __LINE__);
}
// otxn: bearer
// tsh issuer
// w/s: strong
//
// The issuer is who pays, so their hook may roll the redemption back.
for (bool const testStrong : {true, false})
{
test::jtx::Env env{
*this,
network::makeNetworkConfig(21337, "10", "1000000", "200000"),
features};
auto const issuer = Account("alice", KeyType::ed25519);
auto const bearer = Account("bob");
env.fund(XRP(1000), issuer, bearer);
env.close();
if (!testStrong)
addWeakTSH(env, issuer);
setTSHHook(env, issuer, testStrong);
env(claim(bearer, issuer, makeNote(env, issuer)),
fee(XRP(1)),
ter(tesSUCCESS));
env.close();
testTSHStrongWeak(env, tshSTRONG, __LINE__);
}
}
// SetManifest
// | otxn | tsh | manifest |
// | M | M | N/A |
File diff suppressed because it is too large. Load diff
+20
View File
@@ -10,6 +10,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/CreditNote.h>
#include <xrpl/protocol/ErrorCodes.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/TxFlags.h>
@@ -472,6 +473,25 @@ getTransactionalStakeHolders(STTx const& tx, ReadView const& rv)
case ttCRON_SET: {
break;
}
case ttNOTE_CLAIM: {
// The note's issuer is who pays, so they are a strong stake
// holder: their hook can roll a redemption back. The note has
// already passed preflight by the time hooks run, so a parse
// failure here only means no issuer hook executes.
if (!tx.isFieldPresent(sfNote))
break;
try
{
auto const raw = tx.getFieldVL(sfNote);
auto const note = parseCreditNote(std::string_view(
reinterpret_cast<char const*>(raw.data()), raw.size()));
ADD_TSH(note.account, tshSTRONG);
}
catch (std::exception const&)
{
}
break;
}
case ttMANIFEST_SET: {
// The ephemeral key's logical account, meaning the r-address its
// public key hashes to, is a weak stake holder: the manifest names
+12
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();
+27 -3
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)
+33 -3
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;
+15 -1
View File
@@ -789,6 +789,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();
@@ -1968,7 +1975,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]);
+10
View File
@@ -69,6 +69,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))
{
+8
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))
{
+27
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;
@@ -330,6 +339,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
+4 -1
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;
}
+292
View File
@@ -0,0 +1,292 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
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 <xrpld/app/main/Application.h>
#include <xrpld/app/tx/detail/NoteClaim.h>
#include <xrpld/core/Config.h>
#include <xrpld/ledger/View.h>
#include <xrpl/basics/Log.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/JSONTxSignatures.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/Rate.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <stdexcept>
#include <string_view>
namespace ripple {
// getFieldVL returns by value; the view is into the caller's copy.
static std::string_view
text(Blob const& raw)
{
return std::string_view(
reinterpret_cast<char const*>(raw.data()), raw.size());
}
CreditNote
NoteClaim::note(STTx const& tx)
{
auto const raw = tx.getFieldVL(sfNote);
return parseCreditNote(text(raw));
}
NotTEC
NoteClaim::preflight(PreflightContext const& ctx)
{
if (!ctx.rules.enabled(featureCreditNote))
return temDISABLED;
if (auto const ret = preflight1(ctx); !isTesSuccess(ret))
return ret;
auto const& tx = ctx.tx;
if (tx.getFlags() & tfUniversalMask)
{
JLOG(ctx.j.warn()) << "NoteClaim: invalid flags set.";
return temINVALID_FLAG;
}
// Everything below is a pure function of the transaction, so a failure
// is malformed rather than a claimed fee: the bearer could have checked
// it before submitting.
auto const raw = tx.getFieldVL(sfNote);
CreditNote n;
try
{
n = parseCreditNote(text(raw));
}
catch (std::exception const& e)
{
JLOG(ctx.j.warn()) << "NoteClaim: " << e.what();
return temMALFORMED;
}
// A note binds itself to one network. The transaction's own NetworkID
// rules (preflight1) are not enough: on a legacy network a transaction
// carries none, and the note must still not cross over from another.
if (n.networkID != ctx.app.config().NETWORK_ID)
{
JLOG(ctx.j.warn()) << "NoteClaim: note is for network " << n.networkID;
return temMALFORMED;
}
// Redeeming your own note moves nothing and would still retire every
// earlier note you signed.
if (n.account == tx[sfAccount])
return temREDUNDANT;
// Notes are ed25519, like JsonTx itself: it is what WebCrypto can sign.
auto const pk = tx.getFieldVL(sfPublicKey);
if (publicKeyType(makeSlice(pk)) != KeyType::ed25519)
{
JLOG(ctx.j.warn()) << "NoteClaim: PublicKey must be ed25519.";
return temMALFORMED;
}
// The signature is over the note's bytes exactly as carried, under the
// JsonTx prefix: the text the issuer read is the text being verified.
if (!verify(
PublicKey(makeSlice(pk)),
makeSlice(jsontx_signing_data(text(raw))),
makeSlice(tx.getFieldVL(sfNoteSignature))))
{
JLOG(ctx.j.warn()) << "NoteClaim: note signature does not verify.";
return temBAD_SIGNATURE;
}
return preflight2(ctx);
}
TER
NoteClaim::checkFunds(
ReadView const& view,
CreditNote const& n,
AccountID const& bearer,
beast::Journal j)
{
if (n.amount.native())
{
// The issuer pays from what is spendable above their reserve.
if (xrpLiquid(view, n.account, 0, j) < n.amount)
{
JLOG(j.trace()) << "NoteClaim: issuer cannot cover the note.";
return tecUNFUNDED_PAYMENT;
}
return tesSUCCESS;
}
Issue const& issue = n.amount.issue();
AccountID const& gateway = issue.account;
auto const sleGateway = view.read(keylet::account(gateway));
if (!sleGateway)
return tecNO_ISSUER;
// The bearer receives exactly Amount. Unless one side is the gateway the
// transfer transits it, and the issuer bears the gateway's transfer fee
// on top - the same deal as a plain Payment of Amount with no SendMax.
STAmount cost = n.amount;
if (n.account != gateway && bearer != gateway)
cost = multiply(n.amount, transferRate(view, gateway));
if (accountFunds(view, n.account, cost, fhZERO_IF_FROZEN, j) < cost)
{
JLOG(j.trace()) << "NoteClaim: issuer cannot cover the note.";
return tecUNFUNDED_PAYMENT;
}
// A gateway can always take back its own currency. Anyone else needs a
// trust line already in place: a note never creates one, nor an account.
if (bearer != gateway)
{
auto const sleLine =
view.read(keylet::line(bearer, gateway, issue.currency));
if (!sleLine)
{
JLOG(j.trace()) << "NoteClaim: bearer has no trust line.";
return tecNO_LINE;
}
if (sleGateway->getFlags() & lsfRequireAuth)
{
// The gateway's authorization flag sits on the gateway's side of
// the line, which is the low side iff the bearer sorts higher.
bool const gatewayIsLow = bearer > gateway;
if (!(sleLine->getFlags() &
(gatewayIsLow ? lsfLowAuth : lsfHighAuth)))
return tecNO_AUTH;
}
if (isFrozen(view, bearer, issue.currency, gateway))
return tecFROZEN;
}
// The trust line's limit is deliberately not consulted, as when cashing
// a Check: the bearer is the one submitting, so they want the funds.
return tesSUCCESS;
}
TER
NoteClaim::preclaim(PreclaimContext const& ctx)
{
auto const n = note(ctx.tx);
auto const sleIssuer = ctx.view.read(keylet::account(n.account));
if (!sleIssuer)
{
JLOG(ctx.j.warn()) << "NoteClaim: issuer does not exist.";
return tecNO_TARGET;
}
// The key that signed the note must be the issuer's master key (unless
// disabled) or their regular key, exactly as for a single-signed
// transaction from that account. Signer lists are not honoured: a note
// is one signature over one text.
{
auto const signer =
calcAccountID(PublicKey(makeSlice(ctx.tx.getFieldVL(sfPublicKey))));
bool const master =
signer == n.account && !(sleIssuer->getFlags() & lsfDisableMaster);
bool const regular = sleIssuer->isFieldPresent(sfRegularKey) &&
signer == sleIssuer->getAccountID(sfRegularKey);
if (!master && !regular)
{
JLOG(ctx.j.warn())
<< "NoteClaim: PublicKey is not authorised for the issuer.";
return tecNO_PERMISSION;
}
}
// The validity window, against the same clock sfTime uses: the parent
// ledger's close time in milliseconds since the ripple epoch.
std::uint64_t const close =
static_cast<std::uint64_t>(
ctx.view.parentCloseTime().time_since_epoch().count()) *
1000;
// Not valid yet. Retriable, as for a future Sequence: the issuer's clock
// a little ahead of the network's is the common case.
if (close < n.issueTime)
return terPRE_SEQ;
if (close >= n.expiration)
{
JLOG(ctx.j.warn()) << "NoteClaim: note has expired.";
return tecEXPIRED;
}
// Replay. Strictly greater, so a note redeems at most once and no two
// notes share an IssueTime. Not a claimed fee: the losing side of a race
// for the same note did nothing wrong, as for tefPAST_SEQ on a Sequence.
if (sleIssuer->isFieldPresent(sfLastNoteTime) &&
n.issueTime <= sleIssuer->getFieldU64(sfLastNoteTime))
return tefPAST_SEQ;
return checkFunds(ctx.view, n, ctx.tx[sfAccount], ctx.j);
}
TER
NoteClaim::doApply()
{
auto const n = note(ctx_.tx);
auto& view = ctx_.view();
{
// Hooks ran between preclaim and here; nothing they can do moves
// sfLastNoteTime, but the check is cheap and the invariant matters.
auto const sleIssuer = view.read(keylet::account(n.account));
if (!sleIssuer)
return tefINTERNAL;
if (sleIssuer->isFieldPresent(sfLastNoteTime) &&
n.issueTime <= sleIssuer->getFieldU64(sfLastNoteTime))
return tefPAST_SEQ;
}
if (auto const ter = checkFunds(view, n, account_, j_); !isTesSuccess(ter))
return ter;
// XRP to XRP is a balance adjustment; an issued currency goes through
// accountSend, which handles direct issue, redemption and transit (with
// the issuer paying the transfer fee) without touching paths or offers.
TER const ter = n.amount.native()
? transferXRP(view, n.account, account_, n.amount, j_)
: accountSend(view, n.account, account_, n.amount, j_);
if (!isTesSuccess(ter))
return ter;
auto sleIssuer = view.peek(keylet::account(n.account));
if (!sleIssuer)
return tefINTERNAL;
sleIssuer->setFieldU64(sfLastNoteTime, n.issueTime);
view.update(sleIssuer);
ctx_.deliver(n.amount);
return tesSUCCESS;
}
} // namespace ripple
+63
View File
@@ -0,0 +1,63 @@
//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Copyright (c) 2026 XRPL-Labs
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_TX_NOTECLAIM_H_INCLUDED
#define RIPPLE_TX_NOTECLAIM_H_INCLUDED
#include <xrpld/app/tx/detail/Transactor.h>
#include <xrpl/protocol/CreditNote.h>
namespace ripple {
/** Redeems a credit note (featureCreditNote). See CreditNote.h. */
class NoteClaim : public Transactor
{
public:
static constexpr ConsequencesFactoryType ConsequencesFactory{Normal};
explicit NoteClaim(ApplyContext& ctx) : Transactor(ctx)
{
}
static NotTEC
preflight(PreflightContext const& ctx);
static TER
preclaim(PreclaimContext const& ctx);
TER
doApply() override;
private:
/** The note carried by tx; preflight has already accepted it. */
static CreditNote
note(STTx const& tx);
/** Whether the issuer can pay and the bearer can receive, right now. */
static TER
checkFunds(
ReadView const& view,
CreditNote const& note,
AccountID const& bearer,
beast::Journal j);
};
} // namespace ripple
#endif
@@ -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_));
+117
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;
@@ -621,6 +680,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) &&
@@ -703,6 +768,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;
@@ -766,6 +871,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())
{
+6
View File
@@ -326,6 +326,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();
}
+57
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
+3 -1
View File
@@ -57,6 +57,7 @@
#include <xrpld/app/tx/detail/NFTokenCreateOffer.h>
#include <xrpld/app/tx/detail/NFTokenMint.h>
#include <xrpld/app/tx/detail/NFTokenModify.h>
#include <xrpld/app/tx/detail/NoteClaim.h>
#include <xrpld/app/tx/detail/PayChan.h>
#include <xrpld/app/tx/detail/Payment.h>
#include <xrpld/app/tx/detail/PermissionedDomainDelete.h>
@@ -262,7 +263,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)
{
}
+2 -1
View File
@@ -81,7 +81,8 @@ canHaveDeliveredAmountHelp(
{
TxType const tt{serializedTx->getTxnType()};
if (tt != ttPAYMENT && tt != ttCHECK_CASH && tt != ttACCOUNT_DELETE)
if (tt != ttPAYMENT && tt != ttCHECK_CASH && tt != ttACCOUNT_DELETE &&
tt != ttNOTE_CLAIM)
return false;
if (tt == ttCHECK_CASH && !getFix1623Enabled())
+146 -31
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)
{