mirror of
https://github.com/Xahau/xahaud.git
synced 2026-10-28 06:37:07 +00:00
Compare commits
31
Commits
pwa-list
...
credit-note
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d00580a52 | ||
|
|
5db97be877 | ||
|
|
d70fd6fa72 | ||
|
|
f9747d1633 | ||
|
|
0074cf9e95 | ||
|
|
de7afa9895 | ||
|
|
983c5dfe0c | ||
|
|
47b00e9a65 | ||
|
|
ee1d44f7d6 | ||
|
|
5995cc2d00 | ||
|
|
e9cc9e6fcb | ||
|
|
350347c615 | ||
|
|
7112b80d8f | ||
|
|
3de9cc3ba7 | ||
|
|
e35ae4af20 | ||
|
|
d916f108da | ||
|
|
a09c180a93 | ||
|
|
33939f0b02 | ||
|
|
779a07804b | ||
|
|
60caaea495 | ||
|
|
4bbcc2d953 | ||
|
|
72d793d912 | ||
|
|
db25f9799c | ||
|
|
e470d8a70b | ||
|
|
4fb88f5f80 | ||
|
|
b2cdcf429e | ||
|
|
3fd6ee472a | ||
|
|
b2aeb1cd92 | ||
|
|
0f56d2d807 | ||
|
|
e904290e4e | ||
|
|
723103150c |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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, ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -49,6 +49,8 @@ TxFormats::TxFormats()
|
||||
{sfNetworkID, soeOPTIONAL},
|
||||
{sfHookParameters, soeOPTIONAL},
|
||||
{sfHookName, soeOPTIONAL},
|
||||
{sfTime, soeOPTIONAL},
|
||||
{sfJsonTxDelta, soeOPTIONAL},
|
||||
};
|
||||
|
||||
#pragma push_macro("UNWRAP")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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_));
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user